视差贴图(Parallax Mapping),自己在月初就看了教程,开始看了很久都没有看明白,上周看时感觉自己弄明白了,在昨天和今天真正用代码去实现时,发现子所谓的”明白”,实际上还是半知半解.
正是自己这么久的时间才真正开始理解这个概念,现在对此做一些总结,如果有同样遇到问题的童鞋,希望可以这篇博客能够帮助你理解视差贴图.
参考的链接:
1.视差贴图(Parallax Mapping)
2.视差贴图
这篇文章结构如下:
- 为什么要引入视差贴图?
- 引入视差贴图
- 如何改进视差贴图的效果?
- 总结
为什么要引入视差贴图?
首先看下图,
下图相比于上图,有明显的层次感,但实际上,两者所使用的源纹理是相同的.我们知道法线贴图能够极大提升表面细节,使之具有深度感.它也是利用了视错觉,然而,其对深度有着更好的表达,与法线贴图一起用能够产生难以置信的效果.
引入视差贴图
视差贴图(Parallax Mapping)属于位移贴图(Displacement Mapping)技术的一种,它对根据存储在纹理中的几何信息对顶点进行位移或偏移(位移或偏移的目的是产生更真实的层次感).一种实现方式是比如有1000个顶点,根据纹理中的数据对平面特定区域的顶点进行位移.这样的每个纹理像素包含了高度值纹理叫做高度贴图,以上图片中的砖块表面的高度贴图如下所示:
这也是相比于法线贴图增加的一个纹理.整个平面上的每个顶点都根据从高度贴图采样出来的高度值进行偏移.
我们知道,法线贴图可以在低面片数的模型上表现出高面片数模型的很多细节.法线贴图并不是把模型的面数提高了,而是使用法线贴图中的法线来计算光照,通过明暗效果作假,让观察者误以为模型有凹凸.法线贴图只能在明暗效果上作假(模拟凹凸),无法控制表面的凹凸程度.即使我们使用图像软件强制调出一个凹凸相对明显的法线贴图,但正如为什么要引入视差贴图?中的两个图,法线贴图下的凹凸效果明显真实感不高!
这是一张凸起砖块的界面图,绿色箭头表示视线的方向,白色线条表示砖块的横截图.按常识来看,在当前视角所在位置,我们能看到砖块的最远的一点是蓝色点,因为蓝色点后面(红色线条部门)的砖块由于高度较低,被前面挡住了.但是从上面那张使用了法线贴图的地面效果图上可以看到,蓝色点后的砖块并没有被挡住,甚至能够看到黄色点的位置,这种效果明显是不正确的.而这是法线贴图无法避免的问题,因为上文已经说过,法线贴图只能模拟明暗,也就是说最多只能将红色线条部分变暗(借此来模拟背光).视差贴图可以解决这个问题,它可以让背面被遮挡住的部门完全不显示出来,除此之外,还能在一定范围内调整砖块凹凸的程度.视差贴图只能模拟作假,它并没有真的改变模型表面.
如图所示,视线e落点是点a,但是因为模型并不是真的有凹凸,而是一个平面,所以真实的落点是在点b.这样就变成了如何将点a纠正到点b的问题.
(上面的部分图片和内容是参考博客,接下来的内容是参考教程的,引入前者内容的原因是,教程中引入内容有点令人迷惑,对初学者不友好,当初自己真正开始理解视差贴图也是借助博客的)
如上图所示,我们需要描述如何从点A得到点B的纹理坐标,视差贴图尝试通过对从fragment到观察者的方向向量V(视线向量)进行缩放的方式解决这个问题,缩放的大小是A出fragment的高度.所以我们将V的长度缩放为高度贴图在点A处H(A)采样得来的值,上图展示了经缩放得到向量P.
我们随后选出P以及这个向量与平面对齐的坐标作为纹理坐标的偏移量.这能工作是因为向量P是使用从高度贴图得到的高度值计算出来的,所以一个fragment的高度越高位移的量越大(在我现在的理解看来,这个仅仅算是一个经验办法,后面的一个运行结果可以说明这一点).
这个技巧在大多数时候都没有问题,但点B是粗略估算得到的.当表面的高度变化很快的时候,看起来就不会真实,因为向量P最终不会和B接近,就像下图这样:
视差贴图的另一个问题是,当表面被任意旋转以后很难指出从P获取哪一个坐标.我们使用类似法线贴图中切线空间的方式,使用切线空间,这个空间P向量的x和y元素总是与纹理表面对齐.将fragment到观察者的向量V转换到切线空间中,经变换的P向量的x和y元素将于表面的切线和副切线向量对齐.由于切线和副切线向量与表面纹理坐标的方向相同,我们可以用P的x和y元素作为纹理坐标的偏移量,这样就不用考虑表面的方向了.
在这个例子中,我们将视差贴图和法线贴图连用.因为视差贴图生成表面位移了的幻觉,当光照不匹配时这种幻觉就被破坏了.法线贴图通常根据高度贴图生成,发现贴图和高度贴图一起用能够保证光照能和位移相互匹配.
相比于使用高度贴图,使用反色高度贴图(也叫深度贴图)去模拟深度比模拟高度更容易.下图反映了这个轻微的改变:
我们再次获得A和B,但是这次我们用点A的纹理坐标减去向量V得到P.
位移贴图是在片段着色器中实现的,因为三角形表面的所有位移效果都不同.在像素着色器中我们将需要计算fragment到观察者的方向向量V,所以我们需要观察者位置和在切线空间中的fragment位置,顶点着色器代码如下,如果不明白,参考法线贴图
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texcoords;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;
layout (std140) uniform Camera
{
mat4 view;
mat4 projection;
};
uniform mat4 model;
uniform vec3 lightPos;
uniform vec3 viewPos;
out VS_OUT
{
vec3 position;
vec2 texcoord;
vec3 tangentLightPos;
vec3 tangentViewPos;
vec3 tangentPosition;
}vs_out;
void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vec3 position = vec3(model * vec4(position, 1.0f));
vs_out.position = position;
vs_out.texcoord = texcoords;
mat3 normalMatrix = transpose(inverse(mat3(model)));
vec3 T = normalize(normalMatrix * tangent);
vec3 B = normalize(normalMatrix * bitangent);
vec3 N = normalize(normalMatrix * normal);
mat3 TBN = transpose(mat3(T, B, N));
vs_out.tangentLightPos = TBN * lightPos;
vs_out.tangentViewPos = TBN * viewPos;
vs_out.tangentPosition = TBN * position;
}
在像素着色器中,我们实现视差贴图的逻辑,像素着色器看起来是这样的:
#version 330 core
in VS_OUT
{
vec3 position;
vec2 texcoord;
vec3 tangentLightPos;
vec3 tangentViewPos;
vec3 tangentPosition;
}fs_in;
uniform sampler2D diffuseMap;
uniform sampler2D normalMap;
uniform sampler2D depthMap;
uniform float heightScale;
out vec4 color;
vec2 parallaxMap(vec2 texcoord, vec3 viewDir);
void main()
{
vec3 viewDir = normalize(fs_in.tangentViewPos - fs_in.tangentPosition);
vec2 texcoord = parallaxMap(fs_in.texcoord, viewDir);
vec3 texColor = texture2D(diffuseMap, texcoord).rgb;
vec3 normal = texture2D(normalMap, texcoord).rgb;
normal = normalize(normal * 2.0f - 1.0f);
vec3 ambient = 0.1 * texColor;
vec3 lightDir = normalize(fs_in.tangentLightPos - fs_in.tangentPosition);
float diff = max(dot(lightDir, normal), 0.0f);
vec3 diffuse = diff * texColor;
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(halfwayDir, normal), 0.0f), 32.0f);
vec3 specular = spec * vec3(0.2f);
vec3 result = ambient + diffuse + specular;
color = vec4(result, 1.0f);
}
我们定义了一个叫做parallaxMap的函数,它把fragment的纹理坐标和切线空间中的fragment到观察者的方向向量为输入.这个函数返回经位移的纹理坐标.然后我们这些经位移的纹理坐标进行diffuse和法线贴图的采样.最后fragment的diffuse颜色和法线向量就正确地对应于表面的经位移的位置上了.
我们来看看parallaxMap函数的内部:
vec2 parallaxMap(vec2 texcoord, vec3 viewDir)
{
float height = texture2D(depthMap, texcoord).r;
vec2 p = viewDir.xy / viewDir.z * (height * heightScale);
return texcoord - p;
}
这个相对简单的函数使我们所讨论的内容的直接表述,我们用本来的纹理坐标texCoords从高度贴图中采样出当前高度H(A),然后计算出P,x和y元素在切线空间中,viewDir向量除以它的z元素,用fragment的高度对它进行缩放.我们同时引入了一个heightScale的uniform变量,来进行一些额外的控制,因为视差效果如果没有一个缩放参数通常会过于强烈.然后我们用纹理坐标减去P来获取最终的经过位置纹理坐标.
有一个地方需要注意,就是viewDir.xy 除以 viewDir.z那里.因为viewDir向量是经过了标准化的,viewDir.z会在0.01到1.0之间的某处.当viewDir大致平行与表面时,它的z元素接近于0.0,除法会返回比viewDir垂直于表面的时候更大的P向量.所以基本上我们增加了P的大小,当以一个角度朝向一个表面 相比 朝向顶部时它对纹理坐标会进行更大程度的缩放;这会获得更大的真实度.
此时的效果图:
对应的法线贴图效果:
可以看出来,层次感有所提升!
但是将平面旋转90°后,结果如下:
产生上述错误的原因是,上文中关于将A点映射到B点的技巧是存在问题的,它是使用一个样本来确定向量P到B,在这种视角就出现了问题!
如何改进视差贴图的效果?
上文中最后我们解释了平面旋转90°后,产生错误的一个原因是,在映射过程中只使用了一个样本!
改进1:
这里,我们引入陡峭视差映射,陡峭视差映射(Steep Parallax Mapping)是视差映射的扩展,原则是一样的,但不是使用一个样本而是多个样本来确定向量P到B,他能得到更好的结果,它将总深度范围分布到同一个深度/高度的多个层中.从每个层中我们沿着P方向移动采样纹理坐标,直到我们找到了一个采样得到的低于当前层的深度值的深度值(注:这个技巧相比于以前的有所改进,但其还是一个技巧,还是存在问题,它使用低于而不是高于的原因在下图中可以想明白)
我们从上到下遍历深度层,我们把每个深度层和存储在深度贴图中的它的深度值进行对比.如果这个深度值小于深度贴图的值,就意味着这一层的P向量部分在表面之下.我们继续这个处理过程直到有一层的深度高于存储在深度贴图中的值:这个点就在(经过位置的)表面下面.
这个例子中我们可以看到第二层(D(2) = 0.73)的深度贴图的值仍低于第二层的深度值0.4,所以我们继续下一次迭代,这一层的深度值0.6大于深度贴图中采样的深度值(D(3) = 0.37),我们便可以假设第三层向量P是可用的位移几何位置.我们可以用从向量P3的纹理坐标偏移T3来对fragment的纹理坐标进行位移.可以看到随着深度的增加精确度也在提高.
为实现这个技术,我们只需要改变parallaxMap函数,因为所有需要的变量都有了:
vec2 parallaxMap(vec2 texcoord, vec3 viewDir)
{
//number of depth layers
const float numLayers = 10.0f;
//calculate the size of each layer
float layerDepth = 1.0f / numLayers;
//depth or current layer
float currentLayerDepth = 0.0f;
//the amount to shift the texture coordinates per layer(from vector p)
vec2 P = viewDir.xy * heightScale;
vec2 deltaTexCoords = P / numLayers;
[...]
}
我们先定义层的数量,计算每一层的深度,然后计算纹理坐标偏移,每一层我们必须沿着P的方向移动.
然后我们遍历所有层,从上开始,直到找到小于这一层的深度值的深度贴图值:
//get initial values
vec2 currentTexCoords = texcoord;
float currentDepthMapValue = texture2D(depthMap, currentTexCoords).r;
while(currentLayerDepth < currentDepthMapValue)
{
//shift texture coordinates along direction of P
currentTexCoords -= deltaTexCoords;
//get depthmap value at current texture coordinates
currentDepthMapValue = texture2D(depthMap, currentTexCoords).r;
//get depth of next layer
currentLayerDepth += layerDepth;
}
return currentTexCoords;
最后,返回符合要求的那个纹理坐标值,这比单次采样的视差映射更加精确!
运行效果如下:
错位消失,结果正确!
改进2:
我们可以通过对视差贴图的一个属性的利用,对算法进行一点提升.当垂直看一个表面的时候纹理坐标位移时的位移比以一定角度看时的小.我们可以在垂直时使用更少的样本,以一定角度看是增加样本数量:
vec2 parallaxMap(vec2 texcoord, vec3 viewDir)
{
//dynamic numLayers
const float minLayers = 8;
const float maxLayers = 32;
//number of depth layers
float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));
[...]
}
这里我们得到viewDir和正z方向的点乘,使用它的结果根据我们看向表面的角度调整样本数量(注意正z方向等于切线空间中的表面的法线).如果我们所看的方向平行于表面,我们就使用32层!
运行效果:
和改进1中的效果没有看出明显的差异.
改进3:
视差遮蔽映射(Parallax Occlusion Mapping)和陡峭视差映射的原则相同,但不是用触碰的第一个深度层的纹理坐标,而是在触碰之前和之后,在深度层之间进行线性插值.这里再次引用博客中的内容(博客中使用的是高度而不是深度,注意一下就好了,原理是相似的).
从图中可以看出:视线e会和等分线产生交点(红点),直接使用焦点的uv坐标对高度图进行采样,会得到对应的几个高度值(蓝点).最理想的情况下,计算出来的结果正好是在黄点上(所有改进的方向都是这个),观察下红点和蓝点,在黄点左边的蓝点高于红点,在黄色右边的红点高于蓝点,我们可以通过这个规律知道位于黄点两边最近的两个红点和蓝点.这样我们就可以确定黄点就在这两个红点的中间.最后,沿着e的方向,在这两个红点之间进行插值,即可获得黄点的位置了,而插值需要用到h1和h2这两个线段的长度(红蓝两点的间距).
这个插值结果也是近似的,但可以知道其比陡峭视差映射更加精确.
parallaxMap函数代码:
vec2 parallaxMap(vec2 texcoord, vec3 viewDir)
{
//dynamic numLayers
const float minLayers = 8;
const float maxLayers = 32;
//number of depth layers
float numLayers = mix(maxLayers, minLayers, abs(
dot(vec3(0.0, 0.0, 1.0), viewDir)));
//calculate the size of each layer
float layerDepth = 1.0f / numLayers;
//depth or current layer
float currentLayerDepth = 0.0f;
//the amount to shift the texture coordinates per layer(from vector p)
vec2 P = viewDir.xy * heightScale;
vec2 deltaTexCoords = P / numLayers;
//get initial values
vec2 currentTexCoords = texcoord;
float currentDepthMapValue = texture2D(depthMap, currentTexCoords).r;
while(currentLayerDepth < currentDepthMapValue)
{
//shift texture coordinates along direction of P
currentTexCoords -= deltaTexCoords;
//get depthmap value at current texture coordinates
currentDepthMapValue = texture2D(depthMap, currentTexCoords).r;
//get depth of next layer
currentLayerDepth += layerDepth;
}
//get texture coordinates before collision(reverse operation)
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
//get depth after and before collision for linear interpolation
float afterDeltaDepth = currentDepthMapValue - currentLayerDepth;
float beforDeltaDepth = texture2D(depthMap, prevTexCoords).r -
(currentLayerDepth - layerDepth);
//interpolation of texture coordinates
float weight = afterDeltaDepth / (afterDeltaDepth - beforDeltaDepth);
vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1 - weight);
return finalTexCoords;
}
相比于改进2的变化:
//get texture coordinates before collision(reverse operation)
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
//get depth after and before collision for linear interpolation
float afterDeltaDepth = currentDepthMapValue - currentLayerDepth;
float beforDeltaDepth = texture2D(depthMap, prevTexCoords).r -
(currentLayerDepth - layerDepth);
//interpolation of texture coordinates
float weight = afterDeltaDepth / (afterDeltaDepth - beforDeltaDepth);
vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1 - weight);
return finalTexCoords;
在对(位移的)表面几何进行交叉,找到深度层之后,我们获取交叉前的纹理坐标.然后我们计算来自相应深度层的深度之间的距离(即前面说的蓝点和红点的距离),并在两个值之间进行插值.线性插值的方式是在两个层的纹理坐标之间进行的基础插值.函数最后返回最终的经过插值的纹理坐标.
运行效果如下:
总结
视差贴图是提升场景细节非常好的技术,但是使用的时候还是要考虑到它会带来一点不自然.大多数时候视差贴图用在地面和墙壁表面,这种情况下查明表面的轮廓并不容易,同时观察角度往往趋向于垂直表面.这样视差贴图的不自然也就很难被注意到了,对于提升物体的细节可以起到难以置信的效果!