OpenGL笔记:面和顶点的法向量可视化

731 阅读9分钟

前言

又是很久没有写博客了,最近断断续续在跟着learnopengl学习,进度比较缓慢,花了很久才看到几何着色器这章。学这个章节的过程中也遇到了一些问题,先是发现英文文档更新了但是中文文档没有同步过来,于是提交了PR;然后是在做面的法向量可视化练习的时候,发现爆破物体示例代码中计算面的法向量方法疑似有误,经过一些验证以后提了个issue但是没有得到回复,写篇文章先记录一下。

文章相关代码都在我的学习仓库下面 github.com/ColorfulHor…

面和顶点的法向量区别

一开始我并不知道这两者的区别,于是在很多地方产生了疑惑。教程的光照章节中提到顶点的法向量是它所在面的法向量,如果是这样的话,那么对于一个被多个面共享的顶点,它应该拥有多条法线;但是在加载模型章节中,加载模型时又发现顶点和法线是一一对应的,一个顶点只有一条法线。

经过查阅资料后我豁然开朗,如果说每个面的所有顶点都共用一条法线,那么在经过光照计算后整个物体表面就会变得像多面体那样菱角分明,能很清楚的看到模型贴图上面的一个个三角面,它看起来像是下图这样。

image.png

而如果一个顶点被若干个面共享,对这些面的法向量进行加权平均运算就可以得到唯一一个法向量,把这个法向量作为共享顶点的法向量,经过光照计算后这些面看起来就会变得平滑,像下图一样。

image.png

说到这里可能还是会有些疑惑,为什么这样做以后物体表面会变得平滑?其实很简单,我们来理一理顶点、几何、片段着色器中处理的是什么。

  • 顶点着色器接收的是一个个顶点(包含一些属性)
  • 几何着色器接收的是一个个图元,就是一组顶点,比如组成一个三角形的三个顶点(如果使用了EBO的话,共面顶点当然也会被传递到几何着色器多次)
  • 片段着色器接收的是一个个片段(可以理解为像素),这些片段的位置(gl_FragCoord)以及你在片段着色器中声明了in的属性实际上都是经过插值计算得来的;传入这里用于光照计算的法向量(in normal)当然也是经过插值计算后得到的,每个片段根据自身距离这个面所有顶点的偏移计算得到此片段的法向量。你可以回顾一下最开始画三角形的时候,我们只给三个顶点设置了颜色属性,最终三角形的颜色却是从三个角开始混合渐变的。

如果一个面的三个顶点法向量都相同,那么插值计算后每个片段的法向量还是都一样,所以当然会出现图一的情况。

我们来简单写写代码看看效果
我只放上着色器代码,完整代码可以到这里看,在normalVisualization目录下

object.vs

#version 330 core
layout(location = 0) in vec3 mPosition;
layout(location = 1) in vec3 mNormal;
layout(location = 2) in vec2 mTextCoord;

out VS_OUT {
    vec2 textCoord;
    vec3 normal;
    vec3 worldPos;
} vs_out;

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

void main() {
    vs_out.worldPos = vec3(model * vec4(mPosition, 1.0));
    vs_out.textCoord = mTextCoord;
    vs_out.normal = mat3(transpose(inverse(model))) * mNormal;
    gl_Position = projection * view * vec4(vs_out.worldPos, 1.0);
}

object.gs

#version 330 core
// 定义输入输出类型
layout(triangles) in;
layout(triangle_strip, max_vertices = 3) out;

in VS_OUT {
    vec2 textCoord;
    vec3 normal;
    vec3 worldPos;
} gs_in[];

out vec2 texCoord;

out vec3 worldPos;

out vec3 normal;

uniform float time;

vec3 getNormal() {
    vec3 a = vec3(gl_in[0].gl_Position - gl_in[1].gl_Position);
    vec3 b = vec3(gl_in[2].gl_Position - gl_in[1].gl_Position);
    return normalize(cross(b, a));
}

void main() {
    vec3 mNormal = getNormal();
    for(int i = 0; i < 3; i++) {
        gl_Position = gl_in[i].gl_Position;
        texCoord = gs_in[i].textCoord;
        worldPos = gs_in[i].worldPos;
        // 每个顶点都使用面的法向量
        normal = mNormal;
        // 使用模型中顶点的法向量
        // normal = gs_in[i].normal;
        EmitVertex();
    }
    EndPrimitive();
} 

object.fs

#version 330 core
struct Material {
    sampler2D texture_diffuse0;
    sampler2D texture_specular0;
    sampler2D texture_reflect0;
    float shininess;
};

// 方向光源,不衰减,比如阳光
struct DirLight {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    vec3 direction;
};

// 点光源
struct PointLight {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    vec3 position;

    float constant;
    // 线性变化率
    float linear;
    // 平方变化率
    float quadratic;
};

// 聚光光源,范围有限
struct SpotLight {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    vec3 position;
    vec3 direction;

    float constant;
    // 线性变化率
    float linear;
    // 平方变化率
    float quadratic;

    // 切光角的余弦值,光源散射角度
    float cutOff;
    // 外切光角,用于平滑过渡
    float outerCutOff;
};

#define NR_POINT_LIGHTS 4

uniform Material material;
uniform vec3 viewPos;
uniform DirLight dirLight;
uniform PointLight pointLights[NR_POINT_LIGHTS];
uniform SpotLight spotLight;

uniform samplerCube skyboxTexture;

// 片段的世界坐标
in vec3 worldPos;
// 观察者坐标
// 片段所在面法向量
in vec3 normal;

in vec2 texCoord;
out vec4 FragColor;

// 方向光源
vec3 calDirLight(DirLight light, vec3 norm, vec3 viewDirection) { 
    // 光源散射到片段的方向
    vec3 lightDirection = normalize(light.direction);
    // 物体贴图
    vec3 tex = vec3(texture(material.texture_diffuse0, texCoord));

    vec3 R = reflect(viewDirection, norm);
    // 反射贴图
    vec3 reflectTexture = vec3(texture(material.texture_reflect0, texCoord));
    vec3 reflection = texture(skyboxTexture, R).rgb * reflectTexture;
    tex += reflection;

    // 点乘计算光源对这个面的影响,因为法向量垂直平面朝上,所以这里取光照的反方向
    float diff = max(dot(norm, -lightDirection), 0.0);
    // 光线基于法线的反射向量
    vec3 reflectDirection = reflect(lightDirection, norm);
    // 镜面反射影响
    float spec = max(dot(-viewDirection, reflectDirection), 0.0);
    // 反光度
    spec = pow(spec, material.shininess);
    
    // 环境光照
    vec3 ambient = light.ambient * tex;
    vec3 diffuse = light.diffuse * diff * tex;
    vec3 specular = light.specular * spec * vec3(texture(material.texture_specular0, texCoord));
    vec3 res = ambient + diffuse + specular;
    return res;
}

// 点光源
vec3 calPointLight(PointLight light, vec3 norm, vec3 viewDirection) { 
    // 光源散射到片段的方向
    vec3 lightDirection = normalize(worldPos - light.position);
    // 物体贴图
    vec3 tex = vec3(texture(material.texture_diffuse0, texCoord));

    vec3 R = reflect(viewDirection, norm);
    // 反射贴图
    vec3 reflectTexture = vec3(texture(material.texture_reflect0, texCoord));
    vec3 reflection = texture(skyboxTexture, R).rgb * reflectTexture;
    tex += reflection;

    // 点乘计算光源对这个面的影响,因为法向量垂直平面朝上,所以这里取光照的反方向
    float diff = max(dot(norm, -lightDirection), 0.0f);
    // 光线基于法线的反射向量
    vec3 reflectDirection = reflect(lightDirection, norm);
    // 镜面反射影响
    float spec = max(dot(-viewDirection, reflectDirection), 0.0);
    // 反光度
    spec = pow(spec, material.shininess);
    float distance = length(light.position - worldPos);
    
    // 距离衰减
    float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * distance * distance);

    // 环境光照
    vec3 ambient = light.ambient * tex;
    vec3 diffuse = light.diffuse * diff * tex;
    vec3 specular = light.specular * spec * texture(material.texture_specular0, texCoord).rgb;
    vec3 res = ambient * attenuation + diffuse * attenuation + specular * attenuation;
    return res;
}

// 聚光光源影响
vec3 calSpotLight(SpotLight light, vec3 norm, vec3 viewDirection) {
    // 光源散射到片段的方向
    vec3 lightDirection = normalize(worldPos - light.position);
    // 光线聚光方向和到片段散射方向的夹角余弦
    float fragAngle = dot(normalize(light.direction), lightDirection);
    // 在外切光角内的强度 = (fragAngle - cos(out)) / (cos(in) - cos(out))
    // 平滑强度在内切光角和外切光角范围内才有意义,限制为0-1
    float intensity = clamp((fragAngle - light.outerCutOff) / (light.cutOff - light.outerCutOff), 0.0, 1.0);
    // 物体贴图
    vec3 tex = vec3(texture(material.texture_diffuse0, texCoord));

    vec3 R = reflect(viewDirection, norm);
    // 反射贴图
    vec3 reflectTexture = vec3(texture(material.texture_reflect0, texCoord));
    vec3 reflection = texture(skyboxTexture, R).rgb * reflectTexture;
    tex += reflection;

    // 环境光照
    vec3 ambient = light.ambient * tex;

    // 点乘计算光源对这个面的影响,因为法向量垂直平面朝上,所以这里取光照的反方向
    float diff = max(dot(norm, -lightDirection), 0.0f);
    vec3 diffuse = light.diffuse * diff * tex;

    // 光线基于法线的反射向量
    vec3 reflectDirection = reflect(lightDirection, norm);
    // 镜面反射影响
    float spec = max(dot(-viewDirection, reflectDirection), 0.0);
    // 反光度
    spec = pow(spec, material.shininess);
    vec3 specular = light.specular * spec * texture(material.texture_specular0, texCoord).rgb;


    float distance = length(light.position - worldPos);
    // 距离衰减
    float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * distance * distance);
    vec3 res = ambient * attenuation * intensity + diffuse * attenuation * intensity + specular * attenuation * intensity;
    return res;
}

uniform sampler2D texture_diffuse0;

void main() {
    // 片段法线
    vec3 norm = normalize(normal);
    // // 视线向量
    vec3 viewDirection = normalize(worldPos - viewPos);
    
    vec3 res = calDirLight(dirLight, norm, viewDirection);

    for(int i = 0; i < NR_POINT_LIGHTS; i++){
        res += calPointLight(pointLights[i], norm, viewDirection);
    }

    // res += calSpotLight(spotLight, norm, viewDirection);

    FragColor = vec4(res, 1.0);
}

关于面的法向量的计算

learnopengl教程中爆破物体中的计算面的代码是这样的

vec3 GetNormal()
{
    vec3 a = vec3(gl_in[0].gl_Position) - vec3(gl_in[1].gl_Position);
    vec3 b = vec3(gl_in[2].gl_Position) - vec3(gl_in[1].gl_Position);
    return normalize(cross(a, b));
}

实际上这是有问题的,对于一个三角面来说,它的三个顶点要么是顺时针排列的,要么是逆时针排列的,上面的代码得到的是顺时针排列的顶点组成的面的法向量,然而模型里的三角面顶点是逆时针排列的(在下个部分用法线可视化来验证)。至于为什么得到的结果看起来是正确的,我想是因为在顶点着色器中直接将顶点坐标变换到了投影空间(乘了projection矩阵)然后传递到了几何着色器,属于是负负得正了。你可以先改改原教程的代码,把投影变换放在最后然后看看是否如我所说。

右手定则求法线示意图

image.png

我认为正确的代码应该是这样的,先在观察空间计算面的法线然后再变换到投影空间,叉乘也应该反过来,如下。

normal.vs

......
void main() {
    // 变换法线到观察空间
    vs_out.normal = mat3(transpose(inverse(view * model))) * mNormal;
    // 这里先不乘投影矩阵,因为要先在观察空间计算法向量可视化
    gl_Position = view * model * vec4(mPosition, 1.0);
}

normal.gs

#version 330 core
......
vec3 getNormal() {
    vec3 a = vec3(gl_in[0].gl_Position - gl_in[1].gl_Position);
    vec3 b = vec3(gl_in[2].gl_Position - gl_in[1].gl_Position);
    return normalize(cross(b, a));
}

void main() {
    vec3 faceNormal = getNormal();
    for(int i = 0; i < 3; i++) {
        vec4 vertex = gl_in[i].gl_Position;
        fColor = vec3(1.0, 1.0, 0.0);
        // 在view-space计算完后变换到投影空间
        gl_Position = projection * vertex;
        EmitVertex();
        gl_Position = projection * (vertex + vec4(faceNormal * MAGNITUDE, 0.0));
        EmitVertex();
        EndPrimitive();
    }
    ......
}

法向量可视化

最后可以通过法向量可视化来看看面和顶点的法向量区别,我们先加载模型,让每个面使用同一个法线,这样可以让表面的三角形变得清晰;然后分别用不同颜色的线画出顶点的面法向量和点法向量。

先要object着色器来绘制模型(上面的代码已经有了),然后需要normal着色器绘制法线,下面贴一下完整代码

normal.vs

#version 330 core
layout(location = 0) in vec3 mPosition;
layout(location = 1) in vec3 mNormal;

out VS_OUT {
    vec3 normal;
} vs_out;

uniform mat4 model;
uniform mat4 view;

void main() {
    // 变换法线到观察空间
    vs_out.normal = mat3(transpose(inverse(view * model))) * mNormal;
    // 这里先不乘投影矩阵,因为要先在观察空间计算法向量可视化
    gl_Position = view * model * vec4(mPosition, 1.0);
}

normal.gs

#version 330 core

layout(triangles) in;
layout(line_strip, max_vertices = 12) out;

uniform mat4 projection;

out vec3 fColor;

in VS_OUT {
    vec3 normal;
} gs_in[];

const float MAGNITUDE = 0.2;

vec3 getNormal() {
    vec3 a = vec3(gl_in[0].gl_Position - gl_in[1].gl_Position);
    vec3 b = vec3(gl_in[2].gl_Position - gl_in[1].gl_Position);
    return normalize(cross(b, a));
}

void main() {
    vec3 faceNormal = getNormal();
    for(int i = 0; i < 3; i++) {
        vec4 vertex = gl_in[i].gl_Position;
        fColor = vec3(1.0, 1.0, 0.0);
        // 在view-space计算完后变换到投影空间
        gl_Position = projection * vertex;
        EmitVertex();
        gl_Position = projection * (vertex + vec4(faceNormal * MAGNITUDE, 0.0));
        EmitVertex();
        EndPrimitive();

        fColor = vec3(0.0, 1.0, 0.0);
        // 在view-space计算完后变换到投影空间
        gl_Position = projection * vertex;
        EmitVertex();
        gl_Position = projection * (vertex + vec4(gs_in[i].normal * MAGNITUDE, 0.0));
        EmitVertex();
        EndPrimitive();
    }
}

normal.fs

#version 330 core

in vec3 fColor;
out vec4 FragColor;

void main() {
    FragColor = vec4(fColor, 1.0);
}

最后效果看起来像这样,靠近一点可以看到一个共面顶点的面的法线(黄色),和加权平均后的顶点的法线(绿色)

image.png

后记

作为一个初学者来说learnopengl确实是一个非常好的教程,但是我始终觉得缺点什么东西,想来想去应该还是老问题,作者站在一个老鸟的角度自动忽略了一些他认为非常基础的东西,导致我这种菜鸟老是有很多地方搞不明白。把它作为一份的提纲掣领的资料可能更合理,许多知识需要自行拓展。