LearnOpenGL笔记——五、高级光照:“法线贴图”和”视差贴图“

五、高级光照:“法线贴图”和“视差贴图”

5.4 法线贴图

  • 以光照算法的视角考虑的话,只有一件事决定物体的形状,这就是垂直于它的法线向量
  • 砖块表面只有一个法线向量,表面完全根据这个法线向量被以一致的方式照亮
  • 如果每个fragment都是用自己的不同的法线会怎样?这样我们就可以根据表面细微的细节对法线向量进行改变;这样就会获得一种表面看起来要复杂得多的幻觉:
    在这里插入图片描述
  • 这种每个fragment使用各自的法线,替代一个面上所有fragment使用同一个法线的技术叫做法线贴图(normal mapping)或凹凸贴图(bump mapping)
  • **开销不大:**因为我们只需要改变每个fragment的法线向量,并不需要改变所有光照公式。现在我们是为每个fragment传递一个法线,不再使用插值表面法线。这样光照使表面拥有了自己的细节。

法线贴图(在OpenGL中的实现)

  • 我们也可以将法线向量的x、y、z元素储存到纹理中,代替颜色的r、g、b元素;法线向量的范围在-1到1之间,所以我们先要将其映射到0到1的范围:
    vec3 rgb_normal = normal * 0.5 + 0.5; // 从 [-1,1] 转换至 [0,1]
    
  • 这会是一种偏蓝色调的纹理(你在网上找到的几乎所有法线贴图都是这样的)。这是因为所有法线的指向都偏向z轴(0, 0, 1)这是一种偏蓝的颜色。
  • 法线向量从z轴方向也向其他方向轻微偏移,颜色也就发生了轻微变化,这样看起来便有了一种深度。
  • 在一个简单的朝向正z轴的平面上,我们可以用这个diffuse纹理和这个法线贴图来渲染前面部分的图片。
  • 然而有个问题限制了刚才讲的那种法线贴图的使用。我们使用的那个法线贴图里面的所有法线向量都是指向正z方向的。
    • 上面的例子能用,是因为那个平面的表面法线也是指向正z方向的。
    • 可是,如果我们在表面法线指向正y方向的平面上使用同一个法线贴图会发生什么?
    • 你可以看到所有法线都指向z方向,它们本该朝着表面法线指向y方向的。
    • 一个可行方案是为每个表面制作一个单独的法线贴图。如果是一个立方体的话我们就需要6个法线贴图,但是如果模型上有无数的朝向不同方向的表面,这就不可行了
    • 另一个稍微有点难的解决方案是,在一个不同的坐标空间中进行光照,这个坐标空间里,法线贴图向量总是指向这个坐标空间的正z方向;所有的光照向量都相对与这个正z方向进行变换。
      • 这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做切线空间(tangent space)

切线空间

  • 法线贴图中的法线向量定义在切线空间中,在切线空间中,法线永远指着正z方向
  • 切线空间是位于三角形表面之上的空间:法线相对于单个三角形的本地参考框架。
  • 它就像法线贴图向量的本地空间;它们都被定义为指向正z方向,无论最终变换到什么方向
  • 使用一个特定的矩阵我们就能将本地/切线空间中的法线向量转成世界或视图空间下,使它们转向到最终的贴图表面的方向。
    • 切线空间的一大好处是我们可以为任何类型的表面计算出一个这样的矩阵
  • 这种矩阵叫做TBN矩阵
    • 这三个字母分别代表tangent、bitangent和normal向量。这是建构这个矩阵所需的向量。
    • 要建构这样一个把切线空间转变为不同空间的变异矩阵,我们需要三个相互垂直的向量,它们沿一个表面的法线贴图对齐于:上、右、前;这和我们在摄像机教程中做的类似。
对于TBN矩阵的数学推导过程请见官网,在此略去

在这里插入图片描述

手工计算切线和副切线
// positions
glm::vec3 pos1(-1.0f, 1.0f, 0.0f);
glm::vec3 pos2(-1.0f, -1.0f, 0.0f);
glm::vec3 pos3(1.0f, -1.0f, 0.0f);
glm::vec3 pos4(1.0f, 1.0f, 0.0f);
// texture coordinates
glm::vec2 uv1(0.0f, 1.0f);
glm::vec2 uv2(0.0f, 0.0f);
glm::vec2 uv3(1.0f, 0.0f);
glm::vec2 uv4(1.0f, 1.0f);
// normal vector
glm::vec3 nm(0.0f, 0.0f, 1.0f);

// calculate tangent/bitangent vectors of both triangles
glm::vec3 tangent1, bitangent1;
glm::vec3 tangent2, bitangent2;
// triangle 1
// ----------
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;

float f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);

tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);

bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
  • 我们预先计算出等式的分数部分f,然后把它和每个向量的元素进行相应矩阵乘法。
  • 如果你把代码和最终的等式对比你会发现,这就是直接套用。
  • 最后我们还要进行标准化,来确保切线/副切线向量最后是单位向量。
  • 因为一个三角形永远是平坦的形状,我们只需为每个三角形计算一个切线/副切线,它们对于每个三角形上的顶点都是一样的。
  • 要注意的是大多数实现通常三角形和三角形之间都会共享顶点。这种情况下开发者通常将每个顶点的法线和切线/副切线等顶点属性平均化,以获得更加柔和的效果。
  • 我们的平面的三角形之间分享了一些顶点,但是因为两个三角形相互并行,因此并不需要将结果平均化,但无论何时只要你遇到这种情况记住它就是件好事。

切线空间法线贴图

  • 在顶点着色器的main函数中我们创建TBN矩阵:
    void main()
    {
       [...]
       vec3 T = normalize(vec3(model * vec4(tangent,   0.0)));
       vec3 B = normalize(vec3(model * vec4(bitangent, 0.0)));
       vec3 N = normalize(vec3(model * vec4(normal,    0.0)));
       mat3 TBN = mat3(T, B, N)
    }
    
    • 我们先将所有TBN向量变换到我们所操作的坐标系中,现在是世界空间,我们可以乘以model矩阵。
    • 然后我们创建实际的TBN矩阵,直接把相应的向量应用到mat3构造器就行。
    • 注意,如果我们希望更精确的话就不要将TBN向量乘以model矩阵,而是使用法线矩阵,因为我们只关心向量的方向,不关心平移和缩放。
    • 从技术上讲,顶点着色器中无需副切线。所有的这三个TBN向量都是相互垂直的所以我们可以在顶点着色器中用T和N向量的叉乘,自己计算出副切线:vec3 B = cross(T, N);
  • 现在我们有了TBN矩阵,如果来使用它呢?通常来说有两种方式使用它,我们会把这两种方式都说明一下:
    • 一、我们直接使用TBN矩阵,这个矩阵可以把切线坐标空间的向量转换到世界坐标空间。因此我们把它传给片段着色器中,把通过采样得到的法线坐标左乘上TBN矩阵,转换到世界坐标空间中,这样所有法线和其他光照变量就在同一个坐标系中了。
    • 二、我们也可以使用TBN矩阵的逆矩阵,这个矩阵可以把世界坐标空间的向量转换到切线坐标空间。因此我们使用这个矩阵左乘其他光照变量,把他们转换到切线空间,这样法线和其他光照变量再一次在一个坐标系中了。
    • 我们用第二种方法因为:
      • 将向量从世界空间转换到切线空间有个额外好处,我们可以把所有相关向量在顶点着色器中转换到切线空间,不用在像素着色器中做这件事。这是可行的,因为lightPos和viewPos不是每个fragment运行都要改变,对于fs_in.FragPos,我们也可以在顶点着色器计算它的切线空间位置。基本上,不需要把任何向量在像素着色器中进行变换,而第一种方法中就是必须的,因为采样出来的法线向量对于每个像素着色器都不一样。
      • 所以现在不是把TBN矩阵的逆矩阵发送给像素着色器,而是将切线空间的光源位置,观察位置以及顶点位置发送给像素着色器。这样我们就不用在像素着色器里进行矩阵乘法了。这是一个极佳的优化,因为顶点着色器通常比像素着色器运行的少。这也是为什么这种方法是一种更好的实现方式。
复杂物体
  • 我们已经说明了如何通过手工计算切线和副切线向量,来使用切线空间和法线贴图。
  • 幸运的是,计算这些切线和副切线向量对于你来说不是经常能遇到的事;
  • 大多数时候,在模型加载器中实现了一次就行了,我们是在使用了Assimp的那个加载器中实现的
  • Assimp有个很有用的配置,在我们加载模型的时候调用aiProcess_CalcTangentSpace
  • 当aiProcess_CalcTangentSpace应用到Assimp的ReadFile函数时,Assimp会为每个加载的顶点计算出柔和的切线和副切线向量,它所使用的方法和我们本教程使用的类似。
    const aiScene* scene = importer.ReadFile(
        path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
    );
    
  • 我们可以通过下面的代码用Assimp获取计算出来的切线空间:
    vector.x = mesh->mTangents[i].x;
    vector.y = mesh->mTangents[i].y;
    vector.z = mesh->mTangents[i].z;
    vertex.Tangent = vector;
    

在这里插入图片描述

  • 在没有太多点的额外开销的情况下法线贴图可以难以置信地提升物体的细节。
    在这里插入图片描述
  • 高精度网格和使用法线贴图的低精度网格几乎区分不出来。所以法线贴图不仅看起来漂亮,它也是一个将高精度多边形转换为低精度多边形而不失细节的重要工具。
最后一件事
  • 关于法线贴图还有最后一个技巧要讨论,它可以在不必花费太多性能开销的情况下稍稍提升画质表现
  • 当在更大的网格上计算切线向量的时候,它们往往有很大数量的共享顶点,当法向贴图应用到这些表面时将切线向量平均化通常能获得更好更平滑的结果。
    • 这样做有个问题,就是TBN向量可能会不能互相垂直,这意味着TBN矩阵不再是正交矩阵了。法线贴图可能会稍稍偏移,但这仍然可以改进。
  • 使用叫做格拉姆-施密特正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。在顶点着色器中我们这样做:
    vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
    vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
    // re-orthogonalize T with respect to N
    T = normalize(T - dot(T, N) * N);
    // then retrieve perpendicular vector B with the cross product of T and N
    vec3 B = cross(T, N);
    mat3 TBN = mat3(T, B, N)
    
  • 这样稍微花费一些性能开销就能对法线贴图进行一点提升。

5.5 视差贴图

  • 视差贴图和光照无关,我在这里是作为法线贴图的技术延续来讨论它的。
  • 视差贴图属于位移贴图(Displacement Mapping)技术的一种,它对根据储存在纹理中的几何信息对顶点进行位移或偏移。
  • 一种实现的方式是比如有1000个顶点,根据纹理中的数据对平面特定区域的顶点的高度进行位移。这样的每个纹理像素包含了高度值纹理叫做高度贴图。一张简单的砖块表面的高度贴图如下所示:
    在这里插入图片描述
  • 整个平面上的每个顶点都根据从高度贴图采样出来的高度值进行位移,根据材质的几何属性平坦的平面变换成凹凸不平的表面。例如一个平坦的平面利用上面的高度贴图进行置换能得到以下结果:
    在这里插入图片描述
  • 置换顶点有一个问题就是平面必须由很多顶点组成才能获得具有真实感的效果,否则看起来效果并不会很好。
  • 一个平坦的表面上有1000个顶点计算量太大了。我们能否不用这么多的顶点就能取得相似的效果呢?事实上,上面的表面就是用6个顶点渲染出来的(两个三角形)
  • 上面的那个表面使用视差贴图技术渲染,位移贴图技术不需要额外的顶点数据来表达深度,它像法线贴图一样采用一种聪明的手段欺骗用户的眼睛。
    在这里插入图片描述
  • 在点A以点B的纹理坐标采样
  • 我们随后选出以及这个向量与平面对齐的坐标作为纹理坐标的偏移量。这能工作是因为向量是使用从高度贴图得到的高度值计算出来的,所以一个fragment的高度越高位移的量越大。
  • 这个技巧在大多数时候都没问题,但点是粗略估算得到的。当表面的高度变化很快的时候,看起来就不会真实,因为向量最终不会和接近,就像下图这样:
    在这里插入图片描述
  • 视差贴图的另一个问题是,当表面被任意旋转以后很难指出从获取哪一个坐标
  • 我们在视差贴图中使用了另一个坐标空间,这个空间向量的x和y元素总是与纹理表面对齐。如果你看了法线贴图教程,你也许猜到了,我们实现它的方法,是的,我们还是在切线空间中实现视差贴图
  • 理论都有了,下面我们来动手实现视差贴图。

视差贴图

  • 我们将使用一个简单的2D平面,在把它发送给GPU之前我们先计算它的切线和副切线向量;和法线贴图教程做的差不多。
  • 我们将向平面贴diffuse纹理、法线贴图以及一个位移贴图
  • 这个例子中我们将视差贴图和法线贴图连用。因为视差贴图生成表面位移了的幻觉,当光照不匹配时这种幻觉就被破坏了。
  • 法线贴图通常根据高度贴图生成,法线贴图和高度贴图一起用能保证光照能和位移想匹配。
  • 你可能已经注意到,上面链接上的那个位移贴图和教程一开始的那个高度贴图相比是颜色是相反的。
  • 这是因为使用**反色高度贴图(也叫深度贴图)**去模拟深度比模拟高度更容易。下图反映了这个轻微的改变:
    在这里插入图片描述
  • 我们通过在着色器中用1.0减去采样得到的高度贴图中的值来取得深度值,而不再是高度值,或者简单地在图片编辑软件中把这个纹理进行反色操作,就像我们对连接中的那个深度贴图所做的一样。
  • 位移贴图是在像素着色器中实现的,因为三角形表面的所有位移效果都不同。在像素着色器中我们将需要计算fragment到观察者到方向向量所以我们需要观察者位置和在切线空间中的fragment位置。
  • 法线贴图教程中我们已经有了一个顶点着色器,它把这些向量发送到切线空间,所以我们可以复制那个顶点着色器。
  • 在这里有件事很重要,我们需要把position和在切线空间中的观察者的位置viewPos发送给像素着色器。
  • 在像素着色器中,我们实现视差贴图的逻辑。
  • 我们定义了一个叫做ParallaxMapping的函数,它把fragment的纹理坐标和切线空间中的fragment到观察者的方向向量为输入。
  • 这个函数返回经位移的纹理坐标。然后我们使用这些经位移的纹理坐标进行diffuse和法线贴图的采样。
  • 这个函数返回经位移的纹理坐标。然后我们使用这些经位移的纹理坐标进行diffuse和法线贴图的采样。
  • 我们来看看ParallaxMapping函数的内部:
    vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
    { 
        float height =  texture(depthMap, texCoords).r;    
        vec2 p = viewDir.xy / viewDir.z * (height * height_scale);
        return texCoords - p;    
    }
    
  • 这个相对简单的函数是我们所讨论过的内容的直接表述。
    • 我们用本来的纹理坐标texCoords从高度贴图中来采样,得到当前fragment的高度。
    • 然后计算出,x和y元素在切线空间中,viewDir向量除以它的z元素,用fragment的高度对它进行缩放。
    • 我们同时引入额一个height_scale的uniform,来进行一些额外的控制,因为视差效果如果没有一个缩放参数通常会过于强烈。
    • 然后我们用减去纹理坐标来获得最终的经过位移纹理坐标。
  • 有一个地方需要注意,就是viewDir.xy除以viewDir.z那里。
    • 因为viewDir向量是经过了标准化的,viewDir.z会在0.0到1.0之间的某处。
    • 当viewDir大致平行于表面时,它的z元素接近于0.0,除法会返回比viewDir垂直于表面的时候更大的向量。
    • 所以,从本质上,相比正朝向表面,当带有角度地看向平面时,我们会更大程度地缩放的大小,从而增加纹理坐标的偏移;这样做在视角上会获得更大的真实度。
  • 有些人更喜欢不在等式中使用viewDir.z,因为普通的视差贴图会在角度上产生不尽如人意的结果;这个技术叫做有偏移量限制的视差贴图(Parallax Mapping with Offset Limiting)。
  • 看起来不错,运行起来也很快,因为我们只要给视差贴图提供一个额外的纹理样本就能工作。
  • 当从一个角度看过去的时候,会有一些问题产生(和法线贴图相似),陡峭的地方会产生不正确的结果
  • 问题的原因是这只是一个大致近似的视差映射。还有一些技巧让我们在陡峭的高度上能够获得几乎完美的结果,即使当以一定角度观看的时候。例如,我们不再使用单一样本,取而代之使用多样本来找到最近点会得到怎样的结果?

陡峭视差映射

  • 陡峭视差映射(Steep Parallax Mapping)是视差映射的扩展,原则是一样的,但不是使用一个样本而是多个样本来确定向量到。
  • 即使在陡峭的高度变化的情况下,它也能得到更好的结果,原因在于该技术通过增加采样的数量提高了精确性。
  • 陡峭视差映射的基本思想是将总深度范围划分为同一个深度/高度的多个层。
  • 从每个层中我们沿着方向移动采样纹理坐标,直到我们找到一个采样低于当前层的深度值。看看下面的图片:
    在这里插入图片描述
  • 为实现这个技术,我们只需要改变ParallaxMapping函数,因为所有需要的变量都有了:
    vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
    { 
        // number of depth layers
        const float minLayers = 8;
        const float maxLayers = 32;
        float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));  
        // calculate the size of each layer
        float layerDepth = 1.0 / numLayers;
        // depth of current layer
        float currentLayerDepth = 0.0;
        // the amount to shift the texture coordinates per layer (from vector P)
        vec2 P = viewDir.xy / viewDir.z * heightScale; 
        vec2 deltaTexCoords = P / numLayers;
      
        // get initial values
        vec2  currentTexCoords     = texCoords;
        float currentDepthMapValue = texture(depthMap, currentTexCoords).r;
          
        while(currentLayerDepth < currentDepthMapValue)
        {
            // shift texture coordinates along direction of P
            currentTexCoords -= deltaTexCoords;
            // get depthmap value at current texture coordinates
            currentDepthMapValue = texture(depthMap, currentTexCoords).r;  
            // get depth of next layer
            currentLayerDepth += layerDepth;  
        }
        
        return currentTexCoords;
    }
    
  • 我们先定义层的数量,计算每一层的深度,最后计算纹理坐标偏移,每一层我们必须沿着P¯的方向进行移动。
  • 然后我们遍历所有层,从上开始,知道找到小于这一层的深度值的深度贴图值。
  • 这里我们循环每一层深度,直到沿着P¯向量找到第一个返回低于(位移)表面的深度的纹理坐标偏移量。从fragment的纹理坐标减去最后的偏移量,来得到最终的经过位移的纹理坐标向量,这次就比传统的视差映射更精确了。
  • 我们可以通过对视差贴图的一个属性的利用,对算法进行一点提升。当垂直看一个表面的时候纹理时位移比以一定角度看时的小。我们可以在垂直看时使用更少的样本,以一定角度看时增加样本数量
    genType mix (genType x, genType y, genType a)、genType mix (genType x, genType y, float a)
    
    • 返回线性混合的x和y,如:x⋅(1−a)+y⋅a
  • 陡峭视差贴图同样有自己的问题。因为这个技术是基于有限的样本数量的,我们会遇到锯齿效果以及图层之间有明显的断层:
    在这里插入图片描述
  • 我们可以通过增加样本的方式减少这个问题,但是很快就会花费很多性能。有些旨在修复这个问题的方法:不适用低于表面的第一个位置,而是在两个接近的深度层进行插值找出更匹配B的。
  • 两种最流行的解决方法叫做Relief Parallax MappingParallax Occlusion MappingRelief Parallax Mapping更精确一些,但是比Parallax Occlusion Mapping性能开销更多。因为Parallax Occlusion Mapping的效果和前者差不多但是效率更高,因此这种方式更经常使用,所以我们将在下面讨论一下。

视差遮蔽映射

  • 视差遮蔽映射(Parallax Occlusion Mapping)和陡峭视差映射的原则相同,但不是用触碰的第一个深度层的纹理坐标,而是在触碰之前和之后,在深度层之间进行线性插值。我们根据表面的高度距离啷个深度层的深度层值的距离来确定线性插值的大小。
  • 视差遮蔽映射的代码基于陡峭视差映射,所以并不难:
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    // number of depth layers
    const float minLayers = 8;
    const float maxLayers = 32;
    float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));  
    // calculate the size of each layer
    float layerDepth = 1.0 / numLayers;
    // depth of current layer
    float currentLayerDepth = 0.0;
    // the amount to shift the texture coordinates per layer (from vector P)
    vec2 P = viewDir.xy / viewDir.z * heightScale; 
    vec2 deltaTexCoords = P / numLayers;
  
    // get initial values
    vec2  currentTexCoords     = texCoords;
    float currentDepthMapValue = texture(depthMap, currentTexCoords).r;
      
    while(currentLayerDepth < currentDepthMapValue)
    {
        // shift texture coordinates along direction of P
        currentTexCoords -= deltaTexCoords;
        // get depthmap value at current texture coordinates
        currentDepthMapValue = texture(depthMap, currentTexCoords).r;  
        // get depth of next layer
        currentLayerDepth += layerDepth;  
    }
    
    // get texture coordinates before collision (reverse operations)
    vec2 prevTexCoords = currentTexCoords + deltaTexCoords;

    // get depth after and before collision for linear interpolation
    float afterDepth  = currentDepthMapValue - currentLayerDepth;
    float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth;
 
    // interpolation of texture coordinates
    float weight = afterDepth / (afterDepth - beforeDepth);
    vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);

    return finalTexCoords;
}
  • 视差遮蔽映射的效果非常好,尽管有一些可以看到的轻微的不真实和锯齿的问题,这仍是一个好交易,因为除非是放得非常大或者观察角度特别陡,否则也看不到。

  • 视差贴图是提升场景细节非常好的技术,但是使用的时候还是要考虑到它会带来一点不自然。
  • 大多数时候视差贴图用在地面和墙壁表面,这种情况下查明表面的轮廓并不容易,同时观察角度往往趋向于垂直于表面。
  • 这样视差贴图的不自然也就很难能被注意到了,对于提升物体的细节可以祈祷难以置信的效果。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值