计算机图形学中的法线

1,035 阅读8分钟

引言

在计算机图形学中,法线(Normal)是一个基础且关键的概念。它们在光照计算、表面交互、物理模拟等多个领域都有重要应用。理解法线的概念及其工作原理,是掌握计算机图形学渲染技术的重要一步。

为什么需要学习法线?

法线是 3D 渲染中光照计算的基础。没有正确的法线信息,3D 模型看起来会是完全平坦的,没有阴影、高光或任何立体感。

什么是法线?

在计算机图形学中,法线是垂直于表面的向量。对于平面来说,法线在整个平面上都是相同的;但对于曲面,法线会随着表面的弯曲而变化。

法线的直观理解

想象你站在一个 3D 模型的表面上,头顶所指的方向就是该点的法线方向。对于一个完美的球体,表面上每个点的法线都指向球心的反方向。

  • 法线总是垂直于表面
  • 法线通常是单位向量(长度为 1)
  • 法线的方向决定了表面看起来是朝向还是背向光源

法线的数学表示

在 3D 空间中,法线通常用三维向量表示。对于一个平面,可以通过平面上的两个不平行向量的叉乘来计算法线。

向量叉乘计算法线

给定平面上的两个向量 A 和 B,它们的叉乘结果是一个垂直于这两个向量的新向量,即平面的法线。

// 向量叉乘计算法线
function crossProduct(vectorA, vectorB) {
    return [        vectorA[1] * vectorB[2] - vectorA[2] * vectorB[1],
        vectorA[2] * vectorB[0] - vectorA[0] * vectorB[2],
        vectorA[0] * vectorB[1] - vectorA[1] * vectorB[0]
    ];
}
// 计算平面法线示例
const vectorA = [1, 0, 0]; // X轴方向的向量
const vectorB = [0, 1, 0]; // Y轴方向的向量
const normal = crossProduct(vectorA, vectorB);
console.log("计算得到的法线:", normal); // 输出: [0, 0, 1],即Z轴方向

注意: 法线向量的方向遵循右手定则:如果你的右手四指从向量 A 弯曲到向量 B,那么大拇指所指的方向就是法线方向。

归一化法线向量

在实际应用中,我们通常需要将法线向量归一化,使其长度为 1。这样可以确保光照计算的一致性和正确性。

// 向量归一化函数
function normalize(vector) {
    const length = Math.sqrt(
        vector[0] * vector[0] + 
        vector[1] * vector[1] + 
        vector[2] * vector[2]
    );
    
    // 避免除以零
    if (length === 0) return [0, 0, 0];
    
    return [
        vector[0] / length,
        vector[1] / length,
        vector[2] / length
    ];
}
// 归一化法线示例
const nonNormalizedNormal = [3, 4, 0];
const normalizedNormal = normalize(nonNormalizedNormal);
console.log("归一化前:", nonNormalizedNormal); // 输出: [3, 4, 0]
console.log("归一化后:", normalizedNormal);   // 输出: [0.6, 0.8, 0],长度为1

计算法线

在计算机图形学中,我们通常需要为 3D 模型的每个顶点或面计算法线。下面介绍几种常见的计算方法。

面法线(Face Normals)

面法线是指垂直于多边形面的法线。对于三角形面,可以通过其三个顶点的位置计算得出。

// 计算三角形面法线
function calculateFaceNormal(vertexA, vertexB, vertexC) {
    // 计算边向量
    const edge1 = [        vertexB[0] - vertexA[0],
        vertexB[1] - vertexA[1],
        vertexB[2] - vertexA[2]
    ];
    
    const edge2 = [        vertexC[0] - vertexA[0],
        vertexC[1] - vertexA[1],
        vertexC[2] - vertexA[2]
    ];
    
    // 计算叉乘
    const normal = crossProduct(edge1, edge2);
    
    // 归一化
    return normalize(normal);
}
// 示例:计算三角形面法线
const vertexA = [0, 0, 0];
const vertexB = [1, 0, 0];
const vertexC = [0, 1, 0];
const faceNormal = calculateFaceNormal(vertexA, vertexB, vertexC);
console.log("三角形面法线:", faceNormal); // 输出: [0, 0, 1]

顶点法线(Vertex Normals)

顶点法线是指与顶点相关联的法线。对于平滑表面,顶点法线通常是共享该顶点的所有面法线的平均值。

// 计算顶点法线
function calculateVertexNormals(vertices, faces) {
    // 初始化所有顶点法线为零向量
    const vertexNormals = Array(vertices.length).fill().map(() => [0, 0, 0]);
    
    // 遍历每个面,累加面法线到对应的顶点
    faces.forEach(face => {
        const vA = vertices[face[0]];
        const vB = vertices[face[1]];
        const vC = vertices[face[2]];
        
        // 计算面法线
        const faceNormal = calculateFaceNormal(vA, vB, vC);
        
        // 累加到每个顶点的法线
        for (let i = 0; i < 3; i++) {
            vertexNormals[face[i]][0] += faceNormal[0];
            vertexNormals[face[i]][1] += faceNormal[1];
            vertexNormals[face[i]][2] += faceNormal[2];
        }
    });
    
    // 归一化所有顶点法线
    return vertexNormals.map(normalize);
}
// 示例:计算简单立方体的顶点法线
const cubeVertices = [    // 前面    [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
    // 后面
    [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]
];
const cubeFaces = [    [0, 1, 2], [0, 2, 3], // 前面
    [1, 5, 6], [1, 6, 2], // 右面
    [5, 4, 7], [5, 7, 6], // 后面
    [4, 0, 3], [4, 3, 7], // 左面
    [3, 2, 6], [3, 6, 7], // 上面
    [4, 5, 1], [4, 1, 0]  // 下面
];
const vertexNormals = calculateVertexNormals(cubeVertices, cubeFaces);
console.log("立方体顶点法线:", vertexNormals);

法线在着色中的应用

法线在光照和着色计算中起着核心作用。它们决定了光线如何与物体表面交互,从而影响表面的亮度和颜色。

兰伯特着色模型(Lambertian Shading)

兰伯特着色模型是一种基于表面法线和光线方向的基本光照模型。表面的亮度与表面法线和光线方向之间的夹角的余弦成正比。

// 兰伯特着色计算
function lambertShading(normal, lightDirection) {
    // 确保法线和光线方向都是单位向量
    const normalizedNormal = normalize(normal);
    const normalizedLightDirection = normalize(lightDirection);
    
    // 计算点积
    const dotProduct = 
        normalizedNormal[0] * normalizedLightDirection[0] +
        normalizedNormal[1] * normalizedLightDirection[1] +
        normalizedNormal[2] * normalizedLightDirection[2];
    
    // 确保结果非负(如果表面背对光源,则为0)
    return Math.max(0, dotProduct);
}
// 示例:计算表面点的光照强度
const surfaceNormal = [0, 0, 1]; // 表面法线朝上
const lightDirection = [0.5, 0, -0.5]; // 光线从斜上方照射
const intensity = lambertShading(surfaceNormal, lightDirection);
console.log("光照强度:", intensity); // 输出约为0.707

Phong 着色模型

Phong 着色模型在兰伯特模型的基础上增加了镜面反射分量,能够模拟光泽表面的高光效果。

// Phong着色计算
function phongShading(normal, lightDirection, viewDirection, shininess) {
    // 兰伯特漫反射分量
    const diffuseIntensity = lambertShading(normal, lightDirection);
    
    // 计算反射光线方向
    const normalizedNormal = normalize(normal);
    const normalizedLightDirection = normalize(lightDirection);
    
    // 反射向量计算: R = 2(N·L)N - L
    const dotNL = 
        normalizedNormal[0] * normalizedLightDirection[0] +
        normalizedNormal[1] * normalizedLightDirection[1] +
        normalizedNormal[2] * normalizedLightDirection[2];
    
    const reflectionDirection = [
        2 * dotNL * normalizedNormal[0] - normalizedLightDirection[0],
        2 * dotNL * normalizedNormal[1] - normalizedLightDirection[1],
        2 * dotNL * normalizedNormal[2] - normalizedLightDirection[2]
    ];
    
    // 计算镜面反射分量
    const normalizedViewDirection = normalize(viewDirection);
    const dotRV = 
        reflectionDirection[0] * normalizedViewDirection[0] +
        reflectionDirection[1] * normalizedViewDirection[1] +
        reflectionDirection[2] * normalizedViewDirection[2];
    
    const specularIntensity = Math.max(0, dotRV);
    const specularComponent = Math.pow(specularIntensity, shininess);
    
    // 返回总光照强度(漫反射 + 镜面反射)
    return diffuseIntensity + specularComponent;
}
// 示例:计算Phong着色
const viewDirection = [0, 0, 1]; // 观察方向
const shininess = 32; // 光泽度参数
const phongIntensity = phongShading(surfaceNormal, lightDirection, viewDirection, shininess);
console.log("Phong光照强度:", phongIntensity);

法线贴图(Normal Mapping)

法线贴图是一种纹理技术,通过存储表面细节的法线信息来模拟复杂的表面细节,而不需要增加实际的几何复杂度。

法线贴图的原理

法线贴图使用 RGB 颜色来存储表面法线信息。在法线贴图中:

  • 红色通道存储法线的 X 分量
  • 绿色通道存储法线的 Y 分量
  • 蓝色通道存储法线的 Z 分量
// 从法线贴图颜色值还原法线向量
function decodeNormalFromTexture(rgbColor) {
    // rgbColor是一个包含R、G、B值的数组,范围从0到255
    const r = rgbColor[0] / 255;
    const g = rgbColor[1] / 255;
    const b = rgbColor[2] / 255;
    
    // 将颜色值从[0,1]范围转换到[-1,1]范围
    const normal = [
        r * 2 - 1,
        g * 2 - 1,
        b * 2 - 1
    ];
    
    // 归一化法线向量
    return normalize(normal);
}
// 示例:从法线贴图颜色值还原法线
const textureColor = [128, 128, 255]; // 典型的蓝色法线贴图颜色
const normalVector = decodeNormalFromTexture(textureColor);
console.log("还原的法线向量:", normalVector); // 输出: [0, 0, 1]

切线空间(Tangent Space)

法线贴图通常在切线空间中定义,这样可以在不同的表面方向上正确应用。切线空间由三个向量定义:

  • 法线向量(Normal):垂直于表面
  • 切线向量(Tangent):沿着纹理 U 方向
  • 副切线向量(Bitangent):沿着纹理 V 方向,由法线和切线叉乘得到
// 计算切线空间矩阵
function calculateTangentSpace(normal, tangent) {
    // 归一化输入向量
    const normalizedNormal = normalize(normal);
    const normalizedTangent = normalize(tangent);
    
    // 计算副切线
    const bitangent = crossProduct(normalizedTangent, normalizedNormal);
    
    // 返回TBN矩阵(切线-副切线-法线矩阵)
    return [
        normalizedTangent[0], normalizedTangent[1], normalizedTangent[2],
        bitangent[0], bitangent[1], bitangent[2],
        normalizedNormal[0], normalizedNormal[1], normalizedNormal[2]
    ];
}
// 示例:计算TBN矩阵
const surfaceNormal = [0, 0, 1]; // 表面法线
const surfaceTangent = [1, 0, 0]; // 表面切线
const tbnMatrix = calculateTangentSpace(surfaceNormal, surfaceTangent);
console.log("切线空间矩阵:", tbnMatrix);

总结

法线是计算机图形学中不可或缺的概念,它们在光照计算、表面渲染和物理模拟中起着关键作用。通过本文,你应该对法线有了更深入的理解,包括:

  • 法线的基本概念:法线是垂直于表面的向量,用于确定表面在空间中的朝向。
  • 法线的计算方法:可以通过向量叉乘计算面法线,通过平均相邻面法线计算顶点法线。
  • 法线在光照中的应用:法线是计算光照效果的基础,如兰伯特漫反射和 Phong 镜面反射。
  • 法线贴图技术:法线贴图通过存储表面细节的法线信息,在不增加几何复杂度的情况下模拟复杂表面细节。

下一步学习建议

  • 学习更高级的光照模型,如 Blinn-Phong 模型和 PBR(基于物理的渲染)
  • 探索其他法线相关的纹理技术,如视差贴图和浮雕贴图
  • 了解法线在实时渲染引擎(如 Unity、Unreal Engine)中的应用