5-视差贴图(Parallax mapping)

视差贴图(Parallax Mapping)和法线贴图差不多,但实现方法不一样。视差贴图也利用了视错觉,且对深度有更好的表达,与法线贴图一起用能够产生更好的效果。

视差贴图

概念

视差贴图属于位移贴图(Displacement Mapping)技术的一种,它根据存储在纹理中的几何信息,对顶点进行位移/偏移。一种实现方法是准备一个1000的平面,根据高度贴图的信息对顶点进行位移。

一个简单的砖块表面的高度贴图如下:

利用该高度贴图,对平面上顶点进行位移后的结果如下:

思想

有个问题是必须使用很多顶点的平面才能获得具有真实感的效果,否则效果不好。但使用很多的顶点意味着有一堆三角形要计算,太麻烦了。可以不用这么多顶点来近似达到同样效果吗?答案是可以的,实际上,上面的表面就是用两个三角形渲染出来的。上面的表面使用视差贴图技术渲染,这种位移贴图技术不需要额外的顶点信息表达深度,类似于法线贴图,它也用一种聪明的技巧去欺骗用户。

视差贴图背后的思想就是:修改纹理坐标,让片段表面看起来变高/低了,基于视角方向和一个高度贴图。看看砖墙表面的剖面图:

其中,粗糙的红线是高度贴图中的几何信息表述;\(\bar{V}\)是着色点到摄像机的方向(viewDir)。如果平面确实位移了,观察者应该在\(B\)看到着色点。然而我们的平面没有实际上的位移,着色点实际上在\(A\)视差贴图偏移了着色点\(A\)的纹理坐标到\(B\),然后我们用\(B\)的纹理坐标采样,观察者就像看到点\(B\)一样。

接下来看看如何在\(A\)处获取到\(B\)的纹理坐标,视差贴图通过以点\(A\)的高度缩放向量\(\bar{V}\)来解决,最终得到缩放好的向量\(\bar{P}\)

然后就能得到纹理坐标的偏移量了,用H(P)近似模拟\(B\)。但由于\(B\)是粗略算出来的,当表面高度变化得很快时,得到的结果就不会真实了:

此外还有一个问题,就是当表面旋转后就很难计算\(\bar{P}\)了,因此还需要在切线空间中计算视差贴图。将\(\bar{V}\)从世界空间转换到切线空间,然后通过求下\(\bar{P}\)(x, y)作为纹理坐标的偏移量。

shader实现

为了简易实现视差贴图,这里使用简易2D平面,并且手动计算了tangentbitangent值。在这个平面上,将附加漫反射贴图法线贴图位移贴图。同时使用法线贴图和位移贴图可以保证光照和顶点“偏移”相匹配。

发现给出的位移贴图颜色和上面的高度贴图是反的,这是因为使用了反色高度贴图,模拟深度比模拟高度更方便:

这里通过对\(\bar{V}\)减去\(A\)的纹理坐标得到\(\bar{P}\),其中H(A)可由1.0-采样深度值得到。在片段着色器中,根据着色点的纹理坐标和观察方向计算偏移纹理坐标的函数如下:

uniform float heightScale;

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

其中,heightScale是引入的额外控制量,因为视差效果没有一个缩放参数会过于强烈。还要注意的是/ viewDir.z,由于viewDir是标准化向量,viewDir.z属于[0, 1],当观察方向大致平行于表面时,viewDir.z接近于0,导致偏移量增加,让结果更真实。

此外,还需要注意偏移后的纹理坐标可能会超出[0, 1],需要丢弃掉不符合条件的纹理坐标:

vec2 texCoords = useParallaxMap ? ParallaxMapping(fs_in.TexCoords, viewDir) : fs_in.TexCoords;
if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0)
    discard;

最终结果如下:

陡峭视差映射

上面的结果可以发现,在陡峭的地方产生了“扭曲”效果。这是因为样本太少了,应该使用多重采样去找到\(B\)点。

概念

这里介绍陡峭视差映射(Steep Parallax Mapping),它是视差映射的一种拓展,并且使用多重采样去找到\(B\)点,即使在陡峭的地方结果也很好。

思想

陡峭视差映射的基本思想是 将总体深度范围划分为相同高度/深度的几层。采用“步进”的方式,沿着\(\bar{P}\)方向一层一层走,直到找到一个采样点\(B\),它的深度值低于当前层的深度值。

在这张图中,\(\bar{P}\)向前步进,步进到深度为0.2时,采样点为\(T_1\),发现深度\(H(T_1)>0.2\),还不行。直到深度为0.6时,采样点为\(T_3\),发现深度\(H(T_3)<0.6\),那\(T_3\)就可以是目标\(B\)点。

shader实现

接下来我们修改ParallaxMapping()即可,首先给深度分个层:

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
    // 给深度分层
    const float numLayers = 10;
    float layerDepth = 1.0 / numLayers;
    // 记录当前层的深度值
    float currentLayerDepth = 0.0;
    // 每层的偏移量
    vec2 P = viewDir.xy * heightScale;
    vec2 deltaTexCoords = P / numLayers;

    ...
}

然后进行步进,直到找到符合条件的采样点:

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
	...    
    // 步进找到合适的采样点
    vec2 currentTexCoords = texCoords;
    float currentDepthMapValue = texture(depthMap, currentTexCoords).r;
    while (currentLayerDepth < currentDepthMapValue)
    {
        currentTexCoords -= deltaTexCoords;
        currentDepthMapValue = texture(depthMap, currentTexCoords).r;
        currentLayerDepth += layerDepth;
    }

    return currentTexCoords; 
}

结果如下,减缓了陡峭点的“扭曲”效果:

此外还能进行优化,当视线和表面垂直时减少采样点;当视线和表面角度逐渐减少时增加采样点:

// 给深度分层
const float minLayers = 8;
const float maxLayers = 32;
float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));
float layerDepth = 1.0 / numLayers;

视差遮蔽映射

现在陡峭点的“扭曲”效果减缓了,但又出现了“断层”效果,这是因为采样点间的变化太突兀,需要通过插值解决。这里通过 视差遮蔽映射 这一方法解决。

概念

视差遮蔽映射(Parallax Occlusion Mapping)在陡峭视差映射的基础上添加了插值概念:

shader实现

在陡峭视差映射的基础上,增加插值代码即可:

// 获取上一个采样点
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
// 获取两采样点的深度值
float afterDepth = currentDepthMapValue - currentLayerDepth;
float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth;
// 线性插值
float weight = afterDepth / (afterDepth - beforeDepth);
vec2 finalTexCoores = prevTexCoords * weight + currentTexCoords * (1.0 - weight);

return finalTexCoores; 

结果如下:

参考资料

  • LearnOpenGL - Parallax Mapping
  • 视差贴图 - LearnOpenGL CN (learnopengl-cn.github.io)