从0开始的OpenGL学习(三十三)-视差贴图

本文主要解决一个问题:

如何使用视差贴图得到更好的表面凹凸效果?

引言

学完法线贴图之后,我们的工具箱中又多了一样有用的工具。工具嘛,都有自己的长处和短处。法线贴图的优点是可以通过变化表面光的散射方向来增强表面的凹凸细节,缺点是无法表现凹凸落差大的表面,比如下面这种:


凹凸落差大的表面

上面这张图的凹凸落差可以说是非常清晰了。不仅有巨大的落差,还有明显的遮挡效果,这种渲染效果不是单纯的法线贴图能实现的了。

为了弥补法线贴图的不足,为了渲染落差更大的表面,我们就需要另外一种工具,这就是我们今天要学的视差贴图(parallax mapping)。不过,视差贴图需要和法线贴图一起使用才能有上图的效果,单纯的视差贴图显示的效果非常诡异,没有什么实际价值。

只有视差贴图的渲染效果惨不忍睹,像是一幅墨迹散开的画一样,我就不拿出来亮瞎你的X眼了

有了视差贴图后,配合上法线贴图,我们就能轻松实现上面的效果。而且,视差贴图的原理也很简单,如果你理解了切线空间的原理,就能很轻松的将这个工具收入到你的工具箱中。

先来看一幅对比图:


法线贴图与视差贴图的对比

感受到不同没,右边的图就是我们要实现的效果。好,收拾一下凌乱的思绪,我们就要开始学习之旅了!

视差贴图

我们为什么要用视差贴图?
说白了,就是由于计算机性能的原因,没法计算那么多面片的渲染信息。但是呢,我们又想要有逼真的显示效果,从而发明出来的一种欺骗眼球的手段。

视差贴图,本质上,是一种位移贴图(Displacement Mapping)。它的原理,是根据视线方向,对当前看到的像素位置进行一定的坐标偏移,显示偏移过后的像素颜色。这样,就能有产生这种凹凸落差很大的视觉效果。光是文字说明不太好理解,不要紧,来看这张图:


根据视线偏移效果

如果我们是在观察一个真实世界中的凹凸表面(比如砖墙,红色表示砖块部分),我们从眼睛的位置看过去,能看到的是图上的点B。这很容易理解。但在计算机的图形世界里,因为我们不会把砖墙这种没什么用的模型做的很精细,我们实际的砖墙模型可能就是个长方体。这样,我们的砖墙表面就是一个非常平整表面,我们能看到的点就是点A。于是,我们就需要通过某种计算方法,将真正看到的点A计算到应该看到的点B,对点B的纹理采样显示,这样,我们才能得到一种真实世界中的砖墙效果。

那么,我们该怎么计算点B的位置呢?一个令人沮丧的消息是,我们无法精确计算出点B的坐标。但是,我们可以计算出一个靠近点B的坐标来代替,如果这个替代点离点B足够近,我们也能得到非常真实的效果。

计算原理

如图所示,假设点A的高度为H(A),点B的高度为H(B)(图中未标明H(B))。要计算多少的偏移量才能使A偏移到B呢?在2D平面中,这个偏移量无法精确的计算出来,一个比较靠谱的近似方法是:从点A开始的观察向量P,将其长度截断到H(A)大小,向量P终点的位置就是我们要采样的位置,即图中的H(P)。

运气好的话,H(P)正好是H(B),运气不好的话就不是。现实情况是,绝大多数情况下,我们都不会有那么好的运气,采样点P要么比点B近,要么比点B远,不过不必担心,除了这种采样方法,我们还有其他更精确的采样方法。

明确原理之后,我们来看看如何计算。先计算向量P的长度表达式。根据向量乘法规则,假设点A的法向量Na为(0,0,1),向量P为(Px, Py, Pz),计算两个向量的点积得到的结果是:

Na * P = |Na| * |P| * cos(x)。

假设x是Na与向量P的夹角。由此展开,我们可以得到:

0 * Px + 0 * Py + 1 * Pz = 1 * 1 * cos(x)

计算角度时,向量P可以转换成单位向量。我们就得到一个最终的表达式:

Pz = cos(x)

接着,列出三角表达式:

cos(x) = Length(Na) / Length(P) => Length(P) = Length(Na) / cos(x) = Length(Na) / Pz

将表达式中的Na换成H(A),向量P的长度与H(A)一样,就得到了最终的表达式:

Length(P) = H(A) / Pz

要注意的是,之所以能将Na假设成(0,0,1),是因为我们当前的计算空间是切线空间。这个知识点在上一篇法线贴图的文章里有详细介绍,出于新手保护的目的,我们来复习一下:

所谓切线空间,就是以表面法向量为z轴,纹理坐标UV为系统x轴和y轴所组成的空间。这个空间存在的唯一目的是在这个空间中进行法线贴图、视差贴图等等的工作十分方便。

深度图vs高度图

在实际的应用中,我们通常会用深度图来代替高度图进行位移计算。因为在平面上,深度比高度更有实际意义。这点小改变对我们上述的原理不会造成任何影响,只是在最后的偏移计算时,需要把+向量的操作改成-向量。这点细节部分我会在代码中注明,所以不用担心。


深度图

我们计算的向量P现在反过来了,如果是高度图,我们的纹理坐标计算方法是:点A坐标+向量P。现在变成深度图之后,我们的计算方法是:点A坐标-向量P。其余的不变,这点不难理解吧?

代码实现

先到这里下载纹理图法线贴图深度图

和上一章的法线贴图计算方式相比,最大的不同就是我们不在世界空间中计算了。所以,我们采用的方法是将观察位置、光源位置、片元位置转换到切线空间,在切线空间中进行视差的计算。讲到这里,你应该猜到了,没错,我们就是在上一章中方法2的基础上进行修改。

顶点着色器代码不需要改变,直接复制过来就行,取一个新文件名,例如:
withParallaxMap.vs

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in vec3 aTangent;
layout (location = 4) in vec3 aBitangent;

out VS_OUT {
    vec3 FragPos;
    vec2 TexCoords;
    vec3 TangentLightPos;
    vec3 TangentViewPos;
    vec3 TangentFragPos;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

uniform vec3 lightPos;
uniform vec3 viewPos;

void main()
{
    vs_out.FragPos = vec3(model * vec4(aPos, 1.0));   
    vs_out.TexCoords = aTexCoords;
    
    mat3 normalMatrix = transpose(inverse(mat3(model)));
    vec3 T = normalize(normalMatrix * aTangent);
    vec3 N = normalize(normalMatrix * aNormal);
    T = normalize(T - dot(T, N) * N);
    vec3 B = cross(N, T);
    
    mat3 TBN = transpose(mat3(T, B, N));    
    vs_out.TangentLightPos = TBN * lightPos;
    vs_out.TangentViewPos  = TBN * viewPos;
    vs_out.TangentFragPos  = TBN * vs_out.FragPos;
        
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}

主要工作集中在片元着色器中。先把上一章中方法二片元着色器复制过来,改名成withParallaxMap.fs。紧接着,添上接收深度图的代码:

uniform sampler2D depthMap;

计算纹理偏移时,我们将计算的方法封装到一个函数中,取名ParallaxMapping。这函数接收两个参数作为输入,分别是纹理坐标和是视向量,计算完成后,将偏移后的纹理坐标作为返回值输出:

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    float depth =  texture(depthMap, texCoords).r;
    vec2 p = viewDir.xy * depth / viewDir.z * 0.1;
    return texCoords - p;    
} 

最关键的是中间一行计算p向量的代码,其中:viewDir.xy表示视向量的纹理坐标,depth/viewDir.z就是上面的H(A)/Pz,0.1是一个修正值。因为我们的计算都是近似计算,所以必须要有一个修正值来进行调整,调整到最合适的修正值。这过程可能很繁琐,你可以用一个uniform变量来代替,在主函数中接受键盘的输入来调整这个数值,看看最终效果如何。

关于修正值,有些文献中会对采样后的深度值进行一次修正,一种修正的方程是h = h * s + b。s表示缩放因子,b表示修正值。这里我直接用了一个数字来0.1来代替。因为反正已经是修正值了,具体数据无法精确计算,还不如直接进行调整找到最好的数据。事实上,连除以viewDIr.z这个操作都可以省略掉,直接乘上修正值,调整修正值到最佳效果就好了。

在主函数中,我们的代码就变成:

void main()
{           
    //采样视差贴图
    vec3 viewDir   = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);
    vec2 texCoords = ParallaxMapping(fs_in.TexCoords,  viewDir);

     // 采样法线贴图
    vec3 normal = texture(normalMap, texCoords).rgb;
    // 转换法向量到[-1,1]范围
    normal = normalize(normal * 2.0 - 1.0);  // 此向量是切线空间中的向量
...
    // 采样漫反射
    vec3 color = texture(diffuseMap, texCoords).rgb;//fs_in.TexCoords).rgb;
...
    // 镜面高光
    //vec3 viewDir = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);
...
}

在main.cpp中加上一些边缘代码后,编译运行,你就能看到这样的效果:


运行效果

非常好,这就是我们上面的效果,不过还有点瑕疵,就在边缘的部分,请看:


边缘瑕疵

超出纹理坐标范围的坐标我们可以直接丢弃,还记得丢弃的代码吗?没错,就是discard:

    if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0)
        discard;

将上面的代码添加到调用ParallaxMapping函数之后,再次运行程序,现在的效果好多了。


运行效果

如果显示不正确,请参考这里的源码进行修改。

等等,这就完了吗?当然不是,这只是最基本的方法,这种方法在我们现在的角度观察是没问题,换一个平一点的角度问题就大了。


普通视差贴图的问题

看到没,在一个比较水平的角度上看,或者高度图的落差一大,表面的显示一塌糊涂。出现这种情况的原因是我们的采样方式太粗糙了,只是计算了一个采样点就采样了。这种粗糙的采样方式在高度变化剧烈或者角度刁钻的情况下就崩盘了。解决的方法也很简单,既然采样一次太少,我们多采样几次不就行了?这种多次采样的方式被称作陡峭视差贴图。

陡峭视差贴图(Steep Parallax Map)

陡峭视差贴图的原理,是设定一个采样层数,每一层都对相应纹理采样,当采样到小于当前层深度的纹理坐标时,返回此纹理坐标。如图:


陡峭视差贴图原理

研究一下上面的图:我们把整个深度分成了5层,分别是:0.2、0.4、0.6、0.8和1.0。陡峭视差贴图是这样进行计算的,首先从第一层,也就是深度值为0.2的那一层开始,采样当前的深度值为1.0,这个值大于当前的层深度0.2,所以需要继续采样。然后,采样第二层(0.4),这层的深度值为0.73,它也大于层深度,所以这个深度值也不能用,需要继续采样。于是到了第三层(0.6)这次采样的深度值是0.37,它小于当前层深度,所以,这个纹理坐标就会被采用。

让我们用代码来实现:

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    //将整个深度区域分为10层
    const float numLayers = 10;
    //每一层的深度
    float layerDepth = 1.0 / numLayers;
    //当前层深度
    float currentLayerDepth = 0.0;
    //纹理坐标的完整范围
    vec2 p = viewDir.xy * 0.1;
    //每一层纹理的变化值
    vec2 deltaTexCoords = p / numLayers;

    vec2 currentTexCoords = texCoords;  //起始纹理坐标
    float currentDepthMapValue = texture(parallaxMap, currentTexCoords).r;  //起始深度

    while(currentLayerDepth < currentDepthMapValue)
    {
        currentTexCoords -= deltaTexCoords;     //我们的p向量是指向眼睛的,而纹理值需要往反方向变化,所以这里是-deltaTexCoords
        currentDepthMapValue = texture(parallaxMap, currentTexCoords).r;  
        currentLayerDepth += layerDepth;  
    }

    return currentTexCoords;   
} 

在我们的实现中,我们将整个深度范围分成了10层。纹理范围就直接用了0.1这个修正值,因为已经不需要height这个值了。具体的计算流程和之前讲的原理一样,就是一层层地采样比较深度值,如果采样的深度值小于当前的层深度,那么采样结束,如果大于,就继续采样。要注意的是纹理坐标的变化是减去当前的向量变化量,因为向量P是指向眼睛的,我们需要往它的反方向增加。

等等,先下载这次运行要用的图片:纹理图法线贴图视差贴图

现在可以编译运行了,赶紧动手,你会看到这样的效果:


运行效果

这就明显没有上图中的塌陷效果了,但是出现了另一个问题:锯齿,非常明显的锯齿。出现锯齿的原因想必我不说你也知道,就是采样数量不够,如果我们把层数改成100,会是什么效果:


运行效果

看吧,锯齿效果很明显就消失了,整个场景看上去都不错。但是!你确定要分100层吗?每个坐标都来100层的采样我们的电脑基本上也就寿终正寝了。我们当然不能让这种事情发生,于是,聪明的前辈先驱们想出了2种方法,分别是:视差遮蔽贴图(Parallax Occlusion Mapping)和浮雕视差贴图(Relief Parallax Mapping)。这两种方法都能以相对100层采样较小的代价来实现平滑表面的效果,比较来说,浮雕视差贴图的效果更好,但是消耗更大,视差遮蔽贴图的效果不如浮雕视差贴图,但它的消耗小。具体的需要就看你的选择了。出于学习目的,这两种方法我们都要来实现一次。先来看视差遮蔽贴图。

视差遮蔽贴图(Parallax Occlusion Mapping)

视差遮蔽贴图的原理,就是取与交点相邻的两个层的层深度和采样深度,计算出两个深度值的权重,根据权重采样两个纹理坐标之间某个位置的纹理坐标,如下图:


视差遮蔽贴图原理

与交点相邻的层深度和采样深度分别是0.6(层深度)、0.37(采样深度)和0.4(层深度)、0.73(采样深度)。最终的纹理坐标也必定在这两个坐标之间,只是到底取哪个坐标呢?我们的方法,就是取图中的P点坐标。先计算H(T2)和H(T3)的值,分别是0.33和0.23,T3的权重就是0.23 / (0.33+0.23)=0.41,然后用T3的纹理坐标乘上(1-0.41),T2的纹理坐标乘上0.41,两个坐标值相加,就得到了我们想要的纹理坐标。

说了这么多,赶紧来修改陡峭视差贴图中的源码,实现视差遮蔽贴图的效果吧:

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    ...  //陡峭视差贴图部分代码
    //前一个纹理坐标点
    vec2 prevTexCoords = currentTexCoords + deltaTexCoords;

    //当前深度差
    float afterDepth = currentLayerDepth - currentDepthMapValue;
    //前一个坐标点深度差
    float beforeDepth = texture(parallaxMap, prevTexCoords).r - (currentLayerDepth - layerDepth);
    //当前坐标点的深度值权重
    float weight = afterDepth / (afterDepth + beforeDepth);
    vec2 finalTexCoords = prevTexCoords * weight +   currentTexCoords * (1.0 - weight);

    return finalTexCoords;   
} 

前面的代码不需要改,在后面加上视差遮蔽效果的实现。先算出前一个点的纹理坐标,计算出两个点的深度差,根据我们之前的原理,计算出权重,最后计算出最终纹理坐标。代码应该非常直观,讲这么多已属废话连篇,赶紧编译运行:


运行效果

怎么样,效果不错吧,跟100层采样效果比也有一拼。

浮雕视差贴图(Relief Parallax Mapping)

浮雕视差贴图的原理和陡峭视差贴图类似,不过它更聪明。它是在获取到了交点左右两个相邻点的纹理坐标和深度信息之后(这点和视差遮蔽贴图类似),再对其进行逼近,采用的方法是2分渐进。就是确定了左右两个坐标点之后,取两坐标的中点位置,用这个坐标来采样深度信息,如果这个深度信息小于层深度,那么这个中点坐标就取代原有的左坐标点;如果这个深度信息大于层深度,那么这个中点坐标就取代原有的右坐标点。然后继续取中点,再做比较,如此往复一定次数之后,采样到的纹理坐标就非常接近真实坐标了。像这样:


原理

把原理翻译成代码就是:

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    ... //陡峭视差贴图的源码

    vec2 dtex = deltaTexCoords / 2;    //纹理步长取半
    float deltaLayerDepth = layerDepth / 2;  //深度步长取半

    //计算当前的纹理和层深度
    currentTexCoords += dtex;
    currentLayerDepth -= deltaLayerDepth;

    const int numSearches = 10;    //进行10次2分渐进
    for (int i = 0; i < numSearches; ++i) {
        //每次纹理步长和深度步长都会减半
        dtex /= 2;
        deltaLayerDepth /= 2;

        //采样当前纹理
        currentDepthMapValue = texture(parallaxMap, currentTexCoords).r; 
        if (currentDepthMapValue > currentLayerDepth) {
            //如果当前深度大于层深度,往左逼近
            currentTexCoords  -= dtex;
            currentLayerDepth += deltaLayerDepth;
        }
        else {
            //如果当前深度小于层深度,往右逼近
            currentTexCoords  += dtex;
            currentLayerDepth -= deltaLayerDepth;
        }
    }

    return currentTexCoords;   
} 

代码非常直观易懂,不多解释,直接编译运行:


运行效果

效果不错,和100层陡峭视差贴图效果类似,和视差遮蔽贴图的效果比就没啥优势了,因为我们只进行了10次2分渐进,如果多进行几次,比如50次,就能从细微处看到区别了。只是,这个代价跟100层采样区别也不大,所以还是推荐视差遮蔽贴图,效果又好速度又快。


视差遮蔽贴图和50次渐近的浮雕视差贴图效果比较

最后,老规矩,上传全部源码以供参考。

唠唠嗑

在学习视差贴图的时候,我遇到的最大困难就是,看到一段给出的代码,里面计算方法跟前面讲的原理差了十万八千里,完全不知道为什么要进行那一步计算。就比如说除以viewDir.z这个操作,为啥呢?里面的推导过程呢?完全没有,导致我在这一个问题上卡了3天,真纠结。所幸最后还是被我脑补出原理来,不得不说真是幸运。还有一个问题就是计算偏移的公式不同的资料中不一样,都能得到准确的效果,就是实现的方式每个作者都有自己的理解。这点,在文章中我也给出了自己的见解,算是对自己有个交代。如果我的理解有误,欢迎各位读者严厉指正。

另外,还有一个问题没有解决,就是在进行视差遮蔽贴图计算的时候,为什么是前一个纹理坐标乘上当前的权重,当前坐标却乘上(1-当前权重)?百思不得其解,还希望路过的高手可以解答一下,万分感谢!

总结

回顾一下这一章中学的内容。这一章我们就是用视差贴图来实现法线贴图不能实现的凹凸效果明显的表面。原理是在当前看到的位置上进行一个坐标偏移,偏移到现实中应该落在的坐标上,然后对这个坐标进行纹理采样,获得逼真的效果。具体的实现方式是:

  • 采用当前纹理深度作为偏移长度进行偏移
  • 采用多层渐近的方式获取最合适的偏移
  • 采用多层渐近,再加上线性插值的方式来获取最合适的偏移
  • 采用多层渐近,再加上2分渐近的方式来获取最合适的偏移

并没有什么神奇的地方,好了,学完收工,我们放松一下!

下一篇
目录
上一篇

参考资料

learnopengl
带偏移限制的视差贴图
Parallax Occlusion Mapping in GLSL