假设我们想要对不规则表面的物体进行建模,例如橘子凹凸的表皮、葡萄干褶皱的表面或月球的陨石坑表面。我们该怎么做?到目前为止,我们已经学会了两种可能的方法:
(a)我们可以对整个不规则表面进行建模,但这么做通常不切实际(一个有许多坑的表面需要大量的顶点);(b)我们可以将不规则表面的纹理图图像应用于平滑的对象。
第二种选择通常比较高效。但是,如果场景中有光源,当光源(或摄像机角度)移动时,我们很快就会发现物体使用了静态纹理渲染(以及物体表面是平滑的),因为纹理上的亮区和暗区不会像真正凹凸不平的表面那样,随着光源或摄像机移动而改变。
在本章中,我们将探讨几种与实现凹凸表面相关的方法,通过使用光照效果,即使在实际对象模型表面平滑的情况下,也能使对象看起来具有逼真的表面纹理。我们将首先观察凹凸贴图和法线贴图,当直接为对象添加微小表面细节会使得计算代价过高时,它们可以为场景中的对象增加相当程度的真实感。我们还将研究通过高度贴图实际扰乱光滑表面中顶点的方法,这对于生成地形(和其他一些用途)非常有用。
10.1 凹凸贴图
在第7章中,我们了解了表面法向量在创建令人信服的光照效果中是至关重要的。像素处的光强度主要由反射角确定,即需要考虑到光源位置、相机位置和像素处的法向量。因此,如果我们能找到生成相应法向量的方法,就可以避免生成与凹凸不平或褶皱表面相对应的顶点。
图10.1展示了对于单个“凸起”修改法向量的概念。
因此,如果我们想让一个物体看起来好像有凹凸(或皱纹,陨石坑等),一种方法是计算当表面确实凹凸不平时其上的法向量。当场景点亮时,光照会让人产生我们所期望的幻觉。这是Blinn在1978年首次提出的[BL78],随着在片段着色器拥有了可以对每个像素进行光照计算的能力,这种方法就变得切实可行了。
程序10.1中展示了顶点着色器和片段着色器的一个示例,这段程序会生成一个带有“高尔夫球”表面的环面,如图10.2所示。其代码几乎与我们之前在程序7.2中看到的相同。片段着色器中唯一显著的变化是——输入的已插值法向量(在原程序中名为“varyingNormal”) 在这里变得凹凸不平了,其方法是对环面模型的原始(未变形)顶点
的X、Y和Z轴应用正弦函数。请注意,这里需要顶点着色器将未经变换的顶点沿管线传递给片段着色器。
以这种方式对法向量进行改变,即在运行时使用数学函数进行计算,称为过程式凹凸贴图。
程序10.1 过程式凹凸贴图
vertShader.glsl
#version 430
layout (location = 0) in vec3 vertPos;
layout (location = 1) in vec3 vertNormal;
out vec3 varyingNormal;
out vec3 varyingLightDir;
out vec3 varyingVertPos;
// 与Phong着色相同,但添加此输出顶点属性
out vec3 originalVertex;
struct PositionalLight
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
vec3 position;
};
struct Material
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
void main(void)
{ varyingVertPos = (mv_matrix * vec4(vertPos,1.0)).xyz;
varyingLightDir = light.position - varyingVertPos;
varyingNormal = (norm_matrix * vec4(vertNormal,1.0)).xyz;
// 添加原始顶点,传递以进行插值
originalVertex = vertPos;
gl_Position = proj_matrix * mv_matrix * vec4(vertPos,1.0);
}
fragShader.glsl
#version 430
in vec3 varyingNormal;
in vec3 varyingLightDir;
in vec3 varyingVertPos;
// 与Phong着色相同,但添加此输入顶点属性
in vec3 originalVertex;
out vec4 fragColor;
struct PositionalLight
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
vec3 position;
};
struct Material
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
void main(void)
{ // normalize the light, normal, and view vectors:
vec3 L = normalize(varyingLightDir);
vec3 N = normalize(varyingNormal);
vec3 V = normalize(-varyingVertPos);
// 添加如下代码以扰乱传入的法向量
float a = 0.25; // a 控制凸起的高度
float b = 100.0; // b 控制凸起的宽度
float x = originalVertex.x;
float y = originalVertex.y;
float z = originalVertex.z;
N.x = varyingNormal.x + a*sin(b*x); // 使用正弦函数扰乱传入法向量
N.y = varyingNormal.y + a*sin(b*y);
N.z = varyingNormal.z + a*sin(b*z);
N = normalize(N);
// 光照计算以及输出的fragColor(未更改)现在使用扰动过的法向量N
vec3 R = normalize(reflect(-L, N));
// get the angle between the light and surface normal:
float cosTheta = dot(L,N);
// angle between the view vector and reflected light:
float cosPhi = dot(V,R);
// compute ADS contributions (per pixel):
fragColor = globalAmbient * material.ambient
+ light.ambient * material.ambient
+ light.diffuse * material.diffuse * max(cosTheta,0.0)
+ light.specular * material.specular *
pow(max(cosPhi,0.0), material.shininess);
}
main.cpp
#include <GL\glew.h>
#include <GLFW\glfw3.h>
#include <SOIL2\soil2.h>
#include <string>
#include <iostream>
#include <fstream>
#include <glm\gtc\type_ptr.hpp> // glm::value_ptr
#include <glm\gtc\matrix_transform.hpp> // glm::translate, glm::rotate, glm::scale, glm::perspective
#include "Torus.h"
#include "Utils.h"
using namespace std;
float toRadians(float degrees) { return (degrees * 2.0f * 3.14159f) / 360.0f; }
#define numVAOs 1
#define numVBOs 4
float cameraX, cameraY, cameraZ;
float torLocX, torLocY, torLocZ;
float lightLocX, lightLocY, lightLocZ;
GLuint renderingProgram;
GLuint vao[numVAOs];
GLuint vbo[numVBOs];
// variable allocation for display
GLuint mvLoc, projLoc, nLoc;
int width, height;
float aspect;
glm::mat4 pMat, vMat, mMat, mvMat, invTrMat;
GLuint globalAmbLoc, ambLoc, diffLoc, specLoc, posLoc, mambLoc, mdiffLoc, mspecLoc, mshiLoc;
glm::vec3 currentLightPos;
float lightPos[3];
Torus myTorus(0.5f, 0.2f, 48);
int numTorusVertices, numTorusIndices;
// white light
float globalAmbient[4] = { 0.7f, 0.7f, 0.7f, 1.0f };
float lightAmbient[4] = { 0.0f, 0.0f, 0.0f, 1.0f };
float lightDiffuse[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
float lightSpecular[4] = { 1.0f, 1.0f, 1.0f, 1.0f };
// gold material
float* matAmb = Utils::goldAmbient();
float* matDif = Utils::goldDiffuse();
float* matSpe = Utils::goldSpecular();
float matShi = Utils::goldShininess();
void setupVertices(void) {
numTorusVertices = myTorus.getNumVertices();
numTorusIndices = myTorus.getNumIndices();
std::vector<int> ind = myTorus.getIndices();
std::vector<glm::vec3> vert = myTorus.getVertices();
std::vector<glm::vec2> tex = myTorus.getTexCoords();
std::vector<glm::vec3> norm = myTorus.getNormals();
std::vector<float> pvalues;
std::vector<float> tvalues;
std::vector<float> nvalues;
for (int i = 0; i < myTorus.getNumVertices(); i++) {
pvalues.push_back(vert[i].x);
pvalues.push_back(vert[i].y);
pvalues.push_back(vert[i].z);
tvalues.push_back(tex[i].s);
tvalues.push_back(tex[i].t);
nvalues.push_back(norm[i].x);
nvalues.push_back(norm[i].y);
nvalues.push_back(norm[i].z);
}
glGenVertexArrays(1, vao);
glBindVertexArray(vao[0]);
glGenBuffers(numVBOs, vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
glBufferData(GL_ARRAY_BUFFER, pvalues.size() * 4, &pvalues[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, vbo[1]);
glBufferData(GL_ARRAY_BUFFER, tvalues.size() * 4, &tvalues[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, vbo[2]);
glBufferData(GL_ARRAY_BUFFER, nvalues.size() * 4, &nvalues[0], GL_STATIC_DRAW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vbo[3]);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, ind.size() * 4, &ind[0], GL_STATIC_DRAW);
}
void installLights(glm::mat4 vMatrix) {
glm::vec3 transformed = glm::vec3(vMatrix * glm::vec4(currentLightPos, 1.0));
lightPos[0] = transformed.x;
lightPos[1] = transformed.y;
lightPos[2] = transformed.z;
// get the locations of the light and material fields in the shader
globalAmbLoc = glGetUniformLocation(renderingProgram, "globalAmbient");
ambLoc = glGetUniformLocation(renderingProgram, "light.ambient");
diffLoc = glGetUniformLocation(renderingProgram, "light.diffuse");
specLoc = glGetUniformLocation(renderingProgram, "light.specular");
posLoc = glGetUniformLocation(renderingProgram, "light.position");
mambLoc = glGetUniformLocation(renderingProgram, "material.ambient");
mdiffLoc = glGetUniformLocation(renderingProgram, "material.diffuse");
mspecLoc = glGetUniformLocation(renderingProgram, "material.specular");
mshiLoc = glGetUniformLocation(renderingProgram, "material.shininess");
// set the uniform light and material values in the shader
glProgramUniform4fv(renderingProgram, globalAmbLoc, 1, globalAmbient);
glProgramUniform4fv(renderingProgram, ambLoc, 1, lightAmbient);
glProgramUniform4fv(renderingProgram, diffLoc, 1, lightDiffuse);
glProgramUniform4fv(renderingProgram, specLoc, 1, lightSpecular);
glProgramUniform3fv(renderingProgram, posLoc, 1, lightPos);
glProgramUniform4fv(renderingProgram, mambLoc, 1, matAmb);
glProgramUniform4fv(renderingProgram, mdiffLoc, 1, matDif);
glProgramUniform4fv(renderingProgram, mspecLoc, 1, matSpe);
glProgramUniform1f(renderingProgram, mshiLoc, matShi);
}
void init(GLFWwindow* window) {
renderingProgram = Utils::createShaderProgram("vertShader.glsl", "fragShader.glsl");
cameraX = 0.0f; cameraY = 0.0f; cameraZ = 1.0f;
torLocX = 0.0f; torLocY = 0.0f; torLocZ = -1.0f;
glfwGetFramebufferSize(window, &width, &height);
aspect = (float)width / (float)height;
pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f);
lightLocX = 5.0f; lightLocY = 2.0f; lightLocZ = 2.0f;
setupVertices();
}
void display(GLFWwindow* window, double currentTime) {
glClear(GL_DEPTH_BUFFER_BIT);
glClearColor(0.0, 0.0, 0.0, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(renderingProgram);
mvLoc = glGetUniformLocation(renderingProgram, "mv_matrix");
projLoc = glGetUniformLocation(renderingProgram, "proj_matrix");
nLoc = glGetUniformLocation(renderingProgram, "norm_matrix");
vMat = glm::translate(glm::mat4(1.0f), glm::vec3(-cameraX, -cameraY, -cameraZ));
mMat = glm::translate(glm::mat4(1.0f), glm::vec3(torLocX, torLocY, torLocZ));
mMat = glm::rotate(mMat, toRadians(35.0f), glm::vec3(1.0f, 0.0f, 0.0f));
mvMat = vMat * mMat;
invTrMat = glm::transpose(glm::inverse(mvMat));
currentLightPos = glm::vec3(lightLocX, lightLocY, lightLocZ);
installLights(vMat);
glUniformMatrix4fv(mvLoc, 1, GL_FALSE, glm::value_ptr(mvMat));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(pMat));
glUniformMatrix4fv(nLoc, 1, GL_FALSE, glm::value_ptr(invTrMat));
glBindBuffer(GL_ARRAY_BUFFER, vbo[0]);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ARRAY_BUFFER, vbo[2]);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(1);
glEnable(GL_CULL_FACE);
glFrontFace(GL_CCW);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, vbo[3]);
glDrawElements(GL_TRIANGLES, myTorus.getIndices().size(), GL_UNSIGNED_INT, 0);
}
void window_size_callback(GLFWwindow* win, int newWidth, int newHeight) {
aspect = (float)newWidth / (float)newHeight;
glViewport(0, 0, newWidth, newHeight);
pMat = glm::perspective(1.0472f, aspect, 0.1f, 1000.0f);
}
int main(void) {
if (!glfwInit()) { exit(EXIT_FAILURE); }
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
GLFWwindow* window = glfwCreateWindow(800, 800, "Chapter6 - program2", NULL, NULL);
glfwMakeContextCurrent(window);
if (glewInit() != GLEW_OK) { exit(EXIT_FAILURE); }
glfwSwapInterval(1);
glfwSetWindowSizeCallback(window, window_size_callback);
init(window);
while (!glfwWindowShouldClose(window)) {
display(window, glfwGetTime());
glfwSwapBuffers(window);
glfwPollEvents();
}
glfwDestroyWindow(window);
glfwTerminate();
exit(EXIT_SUCCESS);
}
10.2 法线贴图
凹凸贴图的一种替代方法是使用查找表来替换法向量。这样我们就可以在不依赖数学函数的情况下,对凸起进行构造,例如月球上的陨石坑所对应的凸起。一种使用查找表的常见方法叫作法线贴图。
为了理解法线贴图的工作原理,我们首先注意,向量通过3字节存储,X Y和Z分量各占1字节,就可以达到合理的精度。这样,我们就可以将法向量存储在彩色图像文件中,其中R G和B分量分别对应于X Y和Z。图像中的
RGB值以字节存储,通常被解释为[0…1]范围内的值,但是向量可以有正负值分量。如果我们将法向量分量限制在[−1…+1]范围内,那么在图像文件中将法向量N存储为像素的简单转换是:
法线贴图使用一个图像文件(称为法线贴图),该图像文件包含在光照下所期望表面外观的法向量。在法线贴图中,向量相对于任意XY平面表示,其X和Y分量表示与“垂直”的偏差,其Z分量设置为1,严格垂直于XY平面的向量(即没有偏差)将表示为(0,0,1),而不垂直的向量将具有非零的X和/或Y分量。我们需要使用上面的公式将值转换至RGB空间;例如,(0,0,1)将存储为(0.5,0.5,1),因为实际偏移的范围为[−1…+1],而RGB值的范围为[0…1]。
我们可以通过纹理单元的另一种妙用来生成这样一幅法线贴图:我们在纹理单元中存储所需的法向量而非颜色。然后,在给定片段中,我们就可以使用采样器从法线贴图中查找值,接下来,我们将所得的值作为法向量,而非输出像素颜色(在纹理贴图中我们是这么做的)。
图10.3展示了一个法线贴图图像文件的例子,通过将GIMP法线贴图插件[GI16]应用于Luna [LU16]纹理而生成。法线贴图图像文件并不适合作为图像查看,我们展示这幅图就是为了指明这一点,法线贴图最终看起来基本都是蓝色的。这是因为图像文件中每个像素的B值(蓝色值)都是1(最大蓝色值),这会让它在作为图像时看起来是“蓝色的”。
图10.4展示了两个不同的法线贴图图像文件(它们都由Luna [LU16]的纹理构建)以及在Blinn-Phong光照模型下将它们应用于球体的结果。
从法线贴图查找到的法向量不能直接使用,因为它们是相对于上述的任意XY平面定义的,并没有考虑它们在物体上的位置以及在相机空间中的方向。这个问题的解决策略是建立一个转换矩阵,用于将法向量转换为相机空间,如下所示。
在对象的每个顶点处,我们考虑与对象相切的平面。顶点处的物体的法向量垂直于该切面。我们在该切面中定义两个相互垂直的向量,同时也垂直于法向量,称为切向量和副切向量(有时称为副法向量)。构造我们期望的变换矩阵要求我们的模型包括每个顶点的切向 量(可以通过计算切向量和法向量的叉积来构建副切向量)。如果模 型中没有定义切向量,则需要通过计算得到它们。在球体的情况下,可以通过计算得到精确的切向量。以下是对程序6.1的修改,其余一点没变:
Sphere.cpp
#include <cmath>
#include <vector>
#include <iostream>
#include <glm\glm.hpp>
#include "Sphere.h"
using namespace std;
Sphere::Sphere() {
init(48);
}
Sphere::Sphere(int prec) {
init(prec);
}
float Sphere::toRadians(float degrees) { return (degrees * 2.0f * 3.14159f) / 360.0f; }
void Sphere::init(int prec) {
numVertices = (prec + 1) * (prec + 1);
numIndices = prec * prec * 6;
for (int i = 0; i < numVertices; i++) { vertices.push_back(glm::vec3()); }
for (int i = 0; i < numVertices; i++) { texCoords.push_back(glm::vec2()); }
for (int i = 0; i < numVertices; i++) { normals.push_back(glm::vec3()); }
for (int i = 0; i < numVertices; i++) { tangents.push_back(glm::vec3()); }
for (int i = 0; i < numIndices; i++) { indices.push_back(0); }
// calculate triangle vertices
for (int i = 0; i <= prec; i++) {
for (int j = 0; j <= prec; j++) {
float y = (float)cos(toRadians(180.0f - i * 180.0f / prec));
float x = -(float)cos(toRadians(j*360.0f / prec))*(float)abs(cos(asin(y)));
float z = (float)sin(toRadians(j*360.0f / (float)(prec)))*(float)abs(cos(asin(y)));
vertices[i*(prec + 1) + j] = glm::vec3(x, y, z);
texCoords[i*(prec + 1) + j] = glm::vec2(((float)j / prec), ((float)i / prec));
normals[i*(prec + 1) + j] = glm::vec3(x, y, z);
// 计算切向量
if (((x == 0) && (y == 1) && (z == 0)) || ((x == 0) && (y == -1) && (z == 0))) {// 如果是北极或南极,
tangents[i*(prec + 1) + j] = glm::vec3(0.0f, 0.0f, -1.0f);
}
// 设置切向量为 -Z 轴
else {
// 否则,计算切向量
tangents[i*(prec + 1) + j] = glm::cross(glm::vec3(0.0f, 1.0f, 0.0f), glm::vec3(x, y, z));
}
}
}
// calculate triangle indices
for (int i = 0; i<prec; i++) {
for (int j = 0; j<prec; j++) {
indices[6 * (i*prec + j) + 0] = i*(prec + 1) + j;
indices[6 * (i*prec + j) + 1] = i*(prec + 1) + j + 1;
indices[6 * (i*prec + j) + 2] = (i + 1)*(prec + 1) + j;
indices[6 * (i*prec + j) + 3] = i*(prec + 1) + j + 1;
indices[6 * (i*prec + j) + 4] = (i + 1)*(prec + 1) + j + 1;
indices[6 * (i*prec + j) + 5] = (i + 1)*(prec + 1) + j;
}
}
}
int Sphere::getNumVertices() { return numVertices; }
int Sphere::getNumIndices() { return numIndices; }
std::vector<int> Sphere::getIndices() { return indices; }
std::vector<glm::vec3> Sphere::getVertices() { return vertices; }
std::vector<glm::vec2> Sphere::getTexCoords() { return texCoords; }
std::vector<glm::vec3> Sphere::getNormals() { return normals; }
std::vector<glm::vec3> Sphere::getTangents() { return tangents; }
对于那些表面不可导以至于无法精确求解切向量的模型,其切向量可以通过近似得到,例如在构造(或加载)模型时,将每个顶点指向下一个顶点的向量作为切向量。请注意,这种近似可能会导致切向量与顶点法向量不严格垂直。因此,如果要实现适用于各种模型的法线贴图,需要考虑这种可能性(我们的解决方案中对此进行了处理)。
切向量与顶点、纹理坐标以及法向量一样,是从缓冲区(VBO)传 递到顶点着色器中的顶点属性。然后,顶点着色器通过应用MV矩阵的逆转置并将结果沿着流水线转发以由光栅器进行插值并最终进入片段着色器,从而对正常向量进行处理。逆转置的应用将法向量和切向量转换为相机空间,之后我们使用叉积构造副切向量。
一旦我们在相机空间中得到法向量、切向量和副切向量,就可以使用它们来构造矩阵(依其分量命名为“TBN”矩阵),该矩阵用于将从法线贴图中检索到的法向量转换为在相机空间中相对于物体表面的法向量。
在片段着色器中,新法向量的计算在calcNewNormal()函数中完成。函数的第三行[包含dot(tangent,normal)]的计算确保切向量垂直于法向量。新的切向量和法向量的叉积就是副切向量。
然后,我们创建一个类型为mat3的3×3矩阵,作为TBN。mat3构造函数接收3个向量作为参数,生成一个矩阵,其中顶行是第一个向量,中间行是第二个向量,底行是第三个向量(类似于从摄像机位置构建视图矩阵,见图3.13)。
着色器使用片段的纹理坐标来提取与当前片段对应的法线贴图单元。着色器在提取时使用采样器变“normMap”,并被绑定到纹理单元0(注意:因此在C++ / OpenGL应用程序中必须将法线贴图图像附加到纹理单元0)。因为需要将颜色分量从纹理中存储范围[0…1]转换为其原始范围[−1 … + 1],我们将其乘以2.0再减去1.0。然后将TBN矩阵应用于所得法向量以产生当前像素的最终法向量。着色器的其余部分与用于Phong光照的片段着色器相同。片段着色器代码基于Etay Meiri [ME11]的版本,如程序10.2所示。
制作法线贴图图像可以使用各种各样的工具。有的图像编辑工具就有制作法线贴图的功能,例如GIMP [GI16]和Photoshop [PH16]。它们通过分析图像中的边缘,推断凸起和凹陷,并产生相应的法线贴图。
图10.5显示了由Hastings-Trew [HT16]基于NASA卫星数据创建的月面纹理图。其相应的法线贴图由GIMP法线贴图插件[GP16],通过处理由 Hastings-Trew创建的黑白版本月面纹理图生成。
vertShader.glsl
#version 430
layout (location = 0) in vec3 vertPos;
layout (location = 1) in vec2 texCoord;
layout (location = 2) in vec3 vertNormal;
layout (location = 3) in vec3 vertTangent;
out vec3 varyingLightDir;
out vec3 varyingVertPos;
out vec3 varyingNormal;
out vec3 varyingTangent;
out vec3 originalVertex;
out vec2 tc;
layout (binding=0) uniform sampler2D s;
layout (binding=1) uniform sampler2D t;
struct PositionalLight
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
vec3 position;
};
struct Material
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
void main(void)
{ varyingVertPos = (mv_matrix * vec4(vertPos,1.0)).xyz;
varyingLightDir = light.position - varyingVertPos;
tc = texCoord;
originalVertex = vertPos;
varyingNormal = (norm_matrix * vec4(vertNormal,1.0)).xyz;
varyingTangent = (norm_matrix * vec4(vertTangent,1.0)).xyz;
gl_Position = proj_matrix * mv_matrix * vec4(vertPos,1.0);
}
fragShader.glsl
#version 430
in vec3 varyingLightDir;
in vec3 varyingVertPos;
in vec3 varyingNormal;
in vec3 varyingTangent;
in vec3 originalVertex;
in vec2 tc;
out vec4 fragColor;
layout (binding=0) uniform sampler2D s;
layout (binding=1) uniform sampler2D t;
struct PositionalLight
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
vec3 position;
};
struct Material
{ vec4 ambient;
vec4 diffuse;
vec4 specular;
float shininess;
};
uniform vec4 globalAmbient;
uniform PositionalLight light;
uniform Material material;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
uniform mat4 norm_matrix;
vec3 calcNewNormal()
{
vec3 normal = normalize(varyingNormal);
vec3 tangent = normalize(varyingTangent);
tangent = normalize(tangent - dot(tangent, normal) * normal); //切向量垂直于法向量
vec3 bitangent = cross(tangent, normal);
mat3 tbn = mat3(tangent, bitangent, normal); 用来变换到相机空间的TBN矩阵
vec3 retrievedNormal = texture(s,tc).xyz;
retrievedNormal = retrievedNormal * 2.0 - 1.0;/// 从RGB空间转换
vec3 newNormal = tbn * retrievedNormal;
newNormal = normalize(newNormal);
return newNormal;
}
void main(void)
{ // 正规化光照向量,法向量和视图向量
vec3 L = normalize(varyingLightDir);
vec3 V = normalize(-varyingVertPos);
vec3 N = calcNewNormal();
// 获得光照向量和曲面法向量之间的角度
float cosTheta = dot(L,N);
// 为Blinn优化计算半向量
vec3 R = normalize(reflect(-L, N));
// 视图向量和反射光向量之间的角度
float cosPhi = dot(V,R);
vec4 texC = texture(t,tc);
// 计算ADS贡献(每个像素)
fragColor = globalAmbient + light.ambient * texC
+ light.diffuse * texC * max(cosTheta,0.0)
+ light.specular * texC * pow(max(cosPhi,0.0), material.shininess);
}
图10.6展示了使用两种不同方式渲染的,用以表现月球表面的球体。图10.6左图中,球体使用了原始的纹理贴图;图10.6右图中,球体使用法线贴图的图像作为纹理(供参考)。它们都没有应用法线贴图。虽然左侧使用了纹理的“月球”非常逼真,但仔细观察可以发现,纹理图案很明显拍摄于阳光从左侧照亮月球的时候,因为其山脊 的阴影投射到了右侧(在底部中心附近的火山口中最明显)。如果我们使用Phong着色为此场景添加光照,然后移动月球、相机或灯光来给场景添加动画,就会发现月球上的阴影不会如我们期望地改变。
此外,随着光源的移动(或相机移动),期望中会在山脊上出现许多镜面高光。但是图10.6左图使用了标准纹理的球体将只产生一个镜面高光,对应于光滑球体上所出现的高光,这看起来非常不现实。 配合法线贴图可以显著提高这类对象在光照下的真实感。
当我们在球体上使用法线贴图(而不是纹理)时,我们会得到图10.7所示的结果。尽管它不像标准纹理那么真实(现在),但是现在它确实响应了光照变化。图10.7的第一张图像中从左侧进行光照,第二张图像中则从右侧进行光照。请注意蓝色和黄色箭头所示部分展示了山脊周围漫反射光的变化以及镜面反射高光的移动。
图10.8展示了在使用Phong光照模型的情况下,将法线贴图与标准纹理相结合的效果。月球的图像通过漫射区域进行了增强,镜面高光区域也会响应光源的移动(或相机或物体移动)。两个图像中的光照分别来自左侧和右侧。
我们的程序现在需要两个纹理——一个用于月球表面图像,一个用于法线贴图——因此需要有两个采样器。片段着色器使用之前在7.6节中所描述的技术,将纹理颜色与经光照计算所得的颜色进行混合, 如程序10.3所示。
程序10.3 纹理加法线贴图
有趣的是,法线贴图可以从多级渐远纹理贴图(Mipmapping)中受益,因为在第5章中看到的纹理化产生的“锯齿”伪影,在使用纹理图像进行法线贴图时也会发生。图10.9分别展示了未使用多级渐远纹理贴图和使用了多级渐远纹理贴图进行法线贴图的月球。尽管在静止的图像中不容易观察到,但是左边的球体(未使用多级渐远纹理贴图)周边有闪烁的伪影。
对于法线贴图而言,各向异性过滤(AF)更有效,它不但减少了闪烁的伪影,同时还保留了细节,如图10.10所示(比较右下角边缘的细节)。图10.11中展示了使用相等的纹理权重和光照权重,光照应用 了法线贴图及AF的情况下得到的结果。
最终的渲染结果并不完美。无论光照如何,原始纹理图像中出现的阴影仍将显示在渲染结果上。此外,虽然法线贴图可以影响漫反射和镜面反射效果,但它无法投射阴影。因此,当表面特征较小时,最适用法线贴图。
10.3 高度贴图
现在我们扩展法线贴图的概念——从纹理图像用于扰动法向量到扰乱顶点位置本身。实际上,以这种方式修改对象的几何体具有一定的优势,例如使表面特征沿着对象的边缘可见,并使特征能够响应阴影贴图。我们将会看到,它还可以帮助构建地形。
一种实用的方法是使用纹理图像来存储高度值,然后使用该高度值来提升(或降低)顶点位置。含有高度信息的图像称为高度图,使 用高度图更改对象的顶点的方法称为高度贴图[1]。高度图通常将高度 信息编码为灰度颜色:(0,0,0)(黑色)=低高度,(1,1,1)(白色)=高高度。这样一来通过算法或使用“画图”程序就可以轻松创建高度图。图像的对比度越高,其表示的高度变化越大。这些概念将在 图10.12(显示随机生成的地图)和图10.13(显示有组织的模式的地 图)中说明。
改变顶点位置是否有用取决于改变的模型。顶点操作可以在顶点着色器中轻松完成,当模型顶点细节级别够高(例如在足够高精度的球体中)时,改变顶点高度的方法效果很好。但是,当模型的顶点数量很少(例如立方体的角)时,渲染对象的表面需要依赖于光栅器中的顶点插值来填充细节。当顶点着色器中可用于改变高度的顶点很少时,许多像素的高度将无法从高度图中检索,而需要由插值生成,从而导致表面细节较差。当然,在片段着色器中是不可能进行顶点操作的,因为这时顶点已被光栅化为像素位置。
程序10.4展示了一个将顶点“向外”(即在表面法向量的方向上)移动的顶点着色器代码。它通过将顶点法向量乘以从高度图检索所得的值,然后将该乘积与顶点位置相加,以“向外”移动顶点。
程序10.4 顶点着色器中的高度贴图
vertShader.glsl
#version 430
layout (location=0) in vec3 vertPos;
layout (location=1) in vec2 texCoord;
layout (location=2) in vec3 vertNormal;
out vec2 tc;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;
layout (binding=0) uniform sampler2D t; // 用于纹理
layout (binding=1) uniform sampler2D h; // 用于高度图
void main(void)
{
// "p"是高度图所改变的顶点位置
// 由于高度图是灰度图,因此使用其任何颜色分量
// 都可以(我们使用"r")。除以5.0用来调整高度
vec4 p = vec4(vertPos,1.0) + vec4((vertNormal*((texture(h, texCoord).r)/5.0f)),1.0f);
tc = texCoord;
gl_Position = proj_matrix * mv_matrix * p;
}
图10.14(见彩插)展示了通过在画图程序中涂鸦创建的简单高度图(左上角)。高度图图像中还绘制了一个白色矩形。绿色版本的高度图(左下角)用作纹理。使用程序10.4中展示的着色器将高度图应 用于100×100的矩形网格模型时,会产生类似“地形”的感觉(如图10.14右图所示)。注意白色矩形是如何生成右边的悬崖的。
图10.14展示的渲染结果还算可以,因为模型(网格和球体)有足够数量的顶点来对高度贴图值进行采样。也就是说,模型具有大量的顶点,而高度图相对粗糙并且以低分辨率充分地采样。然而,仔细观察仍然会发现存在分辨率伪影,例如沿图10.14中地形右侧凸起的矩形盒子的左下边缘。凸起的矩形盒子两侧看起来不是完美矩形,而且颜色有渐变效果,其原因是底层网格100像素×100像素的分辨率无法与 高度图中的白色矩形完全对齐,从而导致纹理的光栅化坐标沿侧面产生伪影。
当尝试将其应用于要求更严苛的高度贴图时,在顶点着色器中进行高度贴图的限制会进一步暴露。考虑图10.5中展示的月球图像。法线贴图在捕获图像细节方面表现非常出色(如图10.9和图10.11所 示),而且由于它是灰度图,因此尝试将其作为高度图应用似乎很自然。但是,基于顶点着色器的高度贴图会无法胜任这个任务,因为顶 点着色器中采样的顶点数(即使对于精度=500的球体)比起图像中的细节级别,仍然太少。相较之下,法线贴图能够很好地捕获细节,因 为在片段着色器中对法线贴图的采样是像素级的。
我们将会在之后的第12章继续学习高度图,在那里我们会了解使用曲面细分着色器生成大量顶点的方法。
补充说明
凹凸贴图或法线贴图的一个基本限制是,虽然它们能够在渲染对象的内部提供表面细节的外观,但是物体轮廓(外边界)无法显示这些细节(它保持平滑)。高度贴图在用于实际修改顶点位置时修复了 这个缺陷,但它也有其自身的局限性。正如我们将在本书后面看到的,有时可以使用几何着色器或曲面细分着色器来增加顶点的数量,使高度贴图更加实用、有效。
我们冒昧地简化了一些凹凸贴图和法线贴图计算。在重要应用中可以使用更准确和/或更有效的解决方案[BN12]。