Unity Shader - 表面凹凸技术汇总

概述

为了提高表面细节,最直观的方法就是制作复杂的网格,但是这样做会明显增加大量的耗费,因此更多是使用以下4种常见的技术:

  • 凹凸贴图(Bump Mapping):根据高度图计算法线
  • 法线贴图(Normal Mapping):直接在贴图中存放法线
  • 视差贴图(Parallax Mapping):根据高度图偏移UV
    • 带偏移限制的视差贴图(Parallax Mapping with offset limiting)
    • 陡峭视差贴图(Steep Parallax Mapping)
    • 浮雕视差贴图(Relief Parallax Mapping)
    • 视差遮蔽贴图(Parallax Occlusion Mapping (POM))
  • 偏置贴图(Displacement Mapping):根据高度图偏移顶点(通常与曲面细分一起用)

下面将逐一介绍这些技术。


凹凸贴图 (Bump Mapping)

给定一张高度图(灰度图,与图像纹理具有相同的分辨率),通过计算相邻像素的高度差值来改变表面法向量。

求解过程

问题:计算(x, y, h(x,y))处的表面法线数据
解答
知道4个邻接点(x+1, y), (x-1, y), (x, y+1), (x, y-1)
可以求得偏导数为:



因此可得x, y两个方向的切向量为:

求叉积可得:

标准化后即可得单位法向量。

代码如下:

float3 CalculateNormal(float2 uv)
{
    float2 du = float2(_DepthMap_TexelSize.x * 0.5, 0);
    float u1 = tex2D(_DepthMap, uv - du);
    float u2 = tex2D(_DepthMap, uv + du);
    float3 tu = float3(1, 0, (u2 - u1) * _Scale);

    float2 dv = float2(0, _DepthMap_TexelSize.y * 0.5);
    float v1 = tex2D(_DepthMap, uv - dv);
    float v2 = tex2D(_DepthMap, uv + dv);
    float3 tv = float3(0, 1, (v2 - v1) * _Scale);

    return normalize(-cross(tv, tu)); //这里加不加负号可以放到高度图的a通道来决定
}

完整代码点这里


法线贴图 (Normal Mapping)

法线贴图直接存储了上面凹凸贴图计算出来的法线向量(TBN切空间中)。
这里的法线是(0,0,1)扰动后的结果,因此x,y分量通常小于z分量,所以贴图的颜色通常会偏蓝。

代码如下:

//float3 normal = tex2D(normalMap, uv).rgb; //从法线贴图读取[0,1]范围的法线
//normal = normalize(normal * 2.0 - 1.0);   //将[0,1]转成[-1,1]
float3 normal = UnpackNormal(packedNormal); //注意法线贴图的Texture Type要设置为Normal Map

完整代码点这里


视差贴图 (Parallax Mapping)

根据视线方向与高度图(深度图)的交点来找到新的UV。
如下图所示:

因为想要准确求出交点的计算量太大,因此更多是使用以下这些近似方案。

1. 视差贴图简单版

直接根据当前UV对应的高度,然后将该高度值乘以视线向量(需要是单位向量),从而得到新的UV值。
如下图所示,我们根据当前的(u,v)得到深度值为d,然后将深度值乘以视线方向,能得到新的(u1,v1),可以看见该结果还是离准确的结果(黄色)比较近的。



实现代码如下:

float2 ParallaxMapping(float2 uv, float3 viewDir_tangent)
{
    float3 viewDir = normalize(viewDir_tangent);
    float height = tex2D(_DepthMap, uv).r;
    //因为viewDir是在切线空间的(xy与uv对齐),所以只用xy偏移就行了
    float2 p = viewDir.xy / viewDir.z * (height * _HeightScale); //_HeightScale用来调整高度(深度)
    return uv - p;
}

完整代码点这里


(效果并不十分明显,最好和上面的法线贴图做对比。)
从上面的效果图可以看到边缘处并没有凹进去,此时可以在计算处新的UV后对超界的部分剔除掉:

float2 uv = ParallaxMapping(i.uv, viewDir);
if(uv.x > 1.0 || uv.y > 1.0 || uv.x < 0.0 || uv.y < 0.0) //去掉边上的一些古怪的失真,在平面上工作得挺好的,这条语句根据实际情况决定加不加
    discard;

可以看见该简单版的实现很简单,但是效果并不十分好,只能用在平缓的凹凸面上,但表面凹凸很明显时,会有明显的失真:



通过分析下面这张图就能知道为什么凹凸明显时会失真:


2. 带偏移量限制的视差贴图 (Parallax Mapping with offset limiting)

为了减轻视线与平面十分持平时(V.z很小导致偏移量过大)产生的怪异效果,可以去掉除以V.z这一步。

float2 ParallaxMapping(float2 uv, float3 viewDir_tangent)
{
    float3 viewDir = normalize(viewDir_tangent);
    float height = tex2D(_DepthMap, uv).r;
    float2 p = viewDir.xy * (height * _HeightScale); 
    return uv - p;
}

完整代码点这里

3. 陡峭视差贴图 (Steep Parallax Mapping)

将[0,1]这个深度范围均分成一定数量,然后从上到下遍历,找到第一个层深度在高度场以下的点。
这种方法就是分多个样本,然后不断取样直到样本在交点之后。


步骤

  1. 找到视线V与第0层的交点T0,层深度是0.0,对应高度场值为0.75,因为该点在高度场上,所以找下一个点。
  2. 找到视线V与第1层的交点T1,层深度是0.125,对应高度场值为0.625,因为该点在高度场上,所以找下一个点。
  3. 找到视线V与第2层的交点T2,层深度是0.25,对应高度场值为0.4,因为该点在高度场上,所以找下一个点。
  4. 找到视线V与第3层的交点T3,层深度是0.375,对应高度场值为0.2,因为该点在高度场下,所以这就是我们要找的点。

代码如下:

float2 ParallaxMapping(float2 uv, float3 viewDir_tangent)
{
    float3 viewDir = normalize(viewDir_tangent);

    float layerNum = lerp(_MaxLayerNum, _MinLayerNum, abs(dot(float3(0,0,1), viewDir)));//一点优化:根据视角来决定分层数
    float layerDepth = 1.0 / layerNum;
    float currentLayerDepth = 0.0;
    float2 deltaTexCoords = viewDir.xy / viewDir.z / layerNum * _HeightScale;

    float2 currentTexCoords = uv;
    float currentDepthMapValue = tex2D(_DepthMap, currentTexCoords).w;

    //unable to unroll loop, loop does not appear to terminate in a timely manner
    //上面这个错误是在循环内使用tex2D导致的,需要加上unroll来限制循环次数或者改用tex2Dlod
    // [unroll(100)]
    while(currentLayerDepth < currentDepthMapValue)
    {
        currentTexCoords -= deltaTexCoords;
        // currentDepthMapValue = tex2D(_DepthMap, currentTexCoords).r;
        currentDepthMapValue = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;
        currentLayerDepth += layerDepth;
    }

    return currentTexCoords;
}

可以看见此时效果已经十分出众了,甚至里面的细节已经能够相互遮蔽了。但是该方法也是有缺点的,当分层过多会降低性能,而分层过少会有断层锯齿的现象,如下:


4. 浮雕视差贴图 (Relief Parallax Mapping)

该方法是对陡峭视差贴图的进一步优化。在陡峭视差贴图的基础上,利用二分查找来细化结果。
如下图,假设我们利用陡峭视差贴图找到了T3,而T是准确的交点,二分查找的次数为3。

  1. 取T2-T3的中点P1,因为P1在下面,因此用P1取代T3
  2. 取T2-P1的中点P2,因为P2在上面,因此用P2取代T2
  3. 取P2-P1的中点P3,因为P3在下面,因此用P3取代P1
  4. 到达二分查找次数上限,结果为P3。

代码如下:

float2 ParallaxMapping(float2 uv, float3 viewDir_tangent)
{
    float layerNum = lerp(_MinLayerNum, _MaxLayerNum, abs(dot(float3(0,0,1), viewDir_tangent)));
    float layerDepth = 1.0 / layerNum;
    float currentLayerDepth = 0.0;
    float2 deltaUV = viewDir_tangent.xy / viewDir_tangent.z * _HeightScale / layerNum;

    float2 currentTexCoords = uv;
    float currentDepthMapValue = tex2D(_DepthMap, currentTexCoords).r;

    //unable to unroll loop, loop does not appear to terminate in a timely manner
    //上面这个错误是在循环内使用tex2D导致的,需要加上unroll来限制循环次数或者改用tex2Dlod
    // [unroll(100)]
    while(currentLayerDepth < currentDepthMapValue)
    {
        currentTexCoords -= deltaUV;
        // currentDepthMapValue = tex2D(_DepthMap, currentTexCoords).r;
        currentDepthMapValue = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;
        currentLayerDepth += layerDepth;
    }

    //二分查找
    float2 halfDeltaUV = deltaUV / 2;
    float halfLayerDepth = layerDepth / 2;

    currentTexCoords += halfDeltaUV;
    currentLayerDepth += halfLayerDepth;

    int numSearches = 5;
    for(int i = 0; i < numSearches; i++)
    {
        halfDeltaUV  = halfDeltaUV / 2;
        halfLayerDepth = halfLayerDepth / 2;

        currentDepthMapValue = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;

        if(currentDepthMapValue > currentLayerDepth)
        {
            currentTexCoords -= halfDeltaUV;
            currentLayerDepth += halfLayerDepth;
        }
        else
        {
            currentTexCoords += halfDeltaUV;
            currentLayerDepth -= halfLayerDepth;
        }
    }

    return currentTexCoords;
}

完整代码点这里

该方法的效果比陡峭视差贴图更好,但是相应的性能消耗也更高。

5. 视差遮挡贴图 (Parallax Occlusion Mapping, POM)

也是一种陡峭视差贴图的优化。在陡峭视差贴图的基础上进行插值。
利用陡峭视差贴图得到最靠近交点的T2和T3后,根据这两者的深度与对应层深度的差值作为比例进行插值。

代码如下:

float2 ParallaxMapping(float2 uv, float3 viewDir_tangent)
{
    float layerNum = lerp(_MaxLayerNum, _MinLayerNum, abs(dot(float3(0,0,1), viewDir_tangent))); //垂直时用更少的样本
    float layerDepth = 1.0 / layerNum;
    float currentLayerDepth = 0.0;
    float2 deltaTexCoords = viewDir_tangent.xy / viewDir_tangent.z * _HeightScale / layerNum;

    float2 currentTexCoords = uv;
    float currentDepthMapValue = tex2D(_DepthMap, currentTexCoords).r;

    //unable to unroll loop, loop does not appear to terminate in a timely manner
    //上面这个错误是在循环内使用tex2D导致的,需要加上unroll来限制循环次数或者改用tex2Dlod
    // [unroll(100)]
    while(currentLayerDepth < currentDepthMapValue)
    {
        currentTexCoords -= deltaTexCoords;
        // currentDepthMapValue = tex2D(_DepthMap, currentTexCoords).r;
        currentDepthMapValue = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;
        currentLayerDepth += layerDepth;
    }

    float2 prevTexCoords = currentTexCoords + deltaTexCoords;
    float prevLayerDepth = currentLayerDepth - layerDepth;

    float afterDepth = currentDepthMapValue - currentLayerDepth;
    float beforeDepth = tex2D(_DepthMap, prevTexCoords).r - prevLayerDepth;
    float weight = afterDepth / (afterDepth - beforeDepth);
    float2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);

    return finalTexCoords;
}

完整代码点这里

该方法的效果和性能都介于Steep Parallax Mapping和Relief Parallax Mapping之间。

6. 带自阴影的视差贴图

上面的几种视差贴图都没有考虑自阴影(即凸起部分能向其他部分投影)。要实现自阴影也不难,和制作深度图一样,此时沿着光线方向指向我们利用视差贴图找到的交点,然后判断该交点是否被其他部分遮蔽了。
实际上大部分操作和视差贴图类似,只是把操作的向量从视线向量改为光线向量而已。


代码如下:

float ParallaxShadow(float3 lightDir_tangent, float2 initialUV, float initialHeight)
{
    float3 lightDir = normalize(lightDir_tangent);

    float shadowMultiplier = 1;

    const float minLayers = 15;
    const float maxLayers = 30;

    //只算正对阳光的面
    if(dot(float3(0, 0, 1), lightDir) > 0)
    {
        float numSamplesUnderSurface = 0;
        float numLayers = lerp(maxLayers, minLayers, abs(dot(float3(0, 0, 1), lightDir))); //根据光线方向决定层数
        float layerHeight = 1 / numLayers;
        float2 texStep = _HeightScale * lightDir.xy / lightDir.z / numLayers;

        float currentLayerHeight = initialHeight - layerHeight;
        float2 currentTexCoords = initialUV + texStep;
        float heightFromTexture = tex2D(_DepthMap, currentTexCoords).r;

        while(currentLayerHeight > 0) 
        {
            if(heightFromTexture <= currentLayerHeight)
            numSamplesUnderSurface += 1; //统计被遮挡的层数

            currentLayerHeight -= layerHeight;
            currentTexCoords += texStep;
            heightFromTexture = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;
        }

        shadowMultiplier = 1 - numSamplesUnderSurface / numLayers; //根据被遮挡的层数来决定阴影深浅
    }

    return shadowMultiplier;
}

完整代码点这里

为了让阴影更好看点,可以进行部分柔化:

while(currentLayerHeight > 0)
{
    if(heightFromTexture < currentLayerHeight)
    {
        numSamplesUnderSurface += 1;
        float newShadowMultiplier = (currentLayerHeight - heightFromTexture) * (1.0 - stepIndex / numLayers);
        shadowMultiplier = max(shadowMultiplier, newShadowMultiplier);
    }

    stepIndex += 1;
    currentLayerHeight -= layerHeight;
    currentTexCoords += texStep;
    heightFromTexture = tex2Dlod(_DepthMap, float4(currentTexCoords, 0, 0)).r;
}

if(numSamplesUnderSurface < 1)
{
    shadowMultiplier = 1;
}
else 
{
    shadowMultiplier = 1.0 - shadowMultiplier;
}

完整代码点这里


偏置贴图 (Displacement Mapping)

直接根据高度图(或深度图)来偏移顶点,通常还需要曲面细分(Tessellation)来增加网格密度。
首先是偏移顶点:

float d = tex2Dlod(_DisplacementTex, float4(v.texcoord.xy, 0, 0)).r * _Displacement;
v.vertex.xyz -= v.normal * d;

完整代码点这里

如果不使用曲面细分而直接在低密度网格中偏移顶点,效果并不好:


DisplacementNoTess

因此通常需要加上曲面细分:

#pragma surface surf BlinnPhong addshadow fullforwardshadows vertex:vert tessellate:tessFixed

float4 tessFixed()
{
    return _Tess;
}

完整代码点这里

这时候即使是低密度网格,效果也是无话可说:


Displacement

毫无疑问Displacement Mapping是这几种技术中效果最好的,但是任何东西都是有利有弊的,该技术也是最耗费性能的。


参考

Unity渲染教程(六):凹凸度
Parallax Mapping - LearnOpenGL
Parallax Occlusion Mapping in GLSL:极为优秀而全面的视差贴图文章
Surface Shaders with DX11 / OpenGL Core Tessellation
《计算机图形学—基于3D图形开发技术》

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

推荐阅读更多精彩内容

  • 关于本系列这是Unity3D Shader入门指南系列的第二篇,本系列面向的对象是新接触Shader开发的Unit...
    JumboWu阅读 1,584评论 0 2
  • <转>我也忘了转自哪里,抱歉,感谢原作者 什么是Shader Shader(着色器)是一段能够针对3D对象进行操作...
    星易乾川阅读 5,590评论 1 16
  • 转载注明出处:点击打开链接 Shader(着色器)是一段能够针对3D对象进行操作、并被GPU所执行的程序。Shad...
    游戏开发小Y阅读 3,358评论 0 4
  • 我们都知道,一个三维场景的画面的好坏,百分之四十取决于模型,百分之六十取决于贴图,可见贴图在画面中所占的重要性。在...
    自由的天空阅读 12,382评论 0 12
  • 缘起 因为《舌尖上的中国》知道的导演 陈晓卿,在微博上知道他出了这本书。 内容 序 沈宏非(写的),蔡澜(没写) ...
    im天行阅读 585评论 0 1