计算机图形学中的顶点焊接与法线平滑:从原理到代码

想象你用乐高积木搭了一座城堡,仔细一看,每块积木的边缘都棱角分明,拼接处能清晰看到缝隙 ------ 这就像计算机里未经处理的 3D 模型。而顶点焊接和法线平滑,就相当于给这座城堡打上腻子、磨平棱角,让它从 "乐高风" 变成光滑细腻的艺术品。今天我们就来拆解这两种图形学魔法的底层逻辑,用 JavaScript 代码还原它们的工作原理。

一、顶点焊接:3D 世界的 "去重术"

什么是顶点焊接?

在 3D 建模时,一个立方体有 8 个顶点,但当你用三角形面片描述它时,每个顶点可能被多个三角形重复存储。就像一群人挤在同一个地铁站出口,明明是同一个位置,却被系统当成了不同的点 ------ 这就是顶点冗余。顶点焊接(Vertex Welding)的任务,就是找出这些 "位置相同的分身",把它们合并成一个实体,既节省存储空间,又为后续的平滑处理铺路。

底层数学逻辑

判断两个顶点是否应该被焊接,本质上是比较它们的三维坐标是否 "足够接近"。在计算机里,由于浮点数精度问题,我们不能直接用 "等于" 来判断,而是设定一个极小的阈值(比如 0.0001)。当两个顶点在 X、Y、Z 三个轴上的差值都小于这个阈值时,就认为它们是同一个点。

这就像现实中测量身高,两个人身高差在 1 毫米以内,我们可以近似认为他们一样高。数学上的表述是:对于顶点 A(x1,y1,z1)和顶点 B(x2,y2,z2),如果 | x1-x2| < 阈值且 | y1-y2| < 阈值且 | z1-z2| < 阈值,则 A 和 B 可被焊接。

JavaScript 实现顶点焊接

假设我们有一组顶点数据,每个顶点包含 x、y、z 坐标和索引:

ini 复制代码
// 原始顶点数据:包含重复顶点
const vertices = [
  { x: 0, y: 0, z: 0, index: 0 },
  { x: 1, y: 0, z: 0, index: 1 },
  { x: 0, y: 1, z: 0, index: 2 },
  { x: 0, y: 0, z: 0, index: 3 }, // 与索引0的顶点位置相同
  { x: 1, y: 1, z: 0, index: 4 },
  { x: 1, y: 0, z: 0, index: 5 }  // 与索引1的顶点位置相同
];
// 顶点焊接函数
function weldVertices(vertices, threshold = 0.0001) {
  const welded = [];
  const indexMap = new Map(); // 记录原始索引到焊接后索引的映射
  
  vertices.forEach(vertex => {
    // 查找是否有可焊接的顶点
    let found = false;
    for (let i = 0; i < welded.length; i++) {
      const wv = welded[i];
      const dx = Math.abs(vertex.x - wv.x);
      const dy = Math.abs(vertex.y - wv.y);
      const dz = Math.abs(vertex.z - wv.z);
      
      if (dx < threshold && dy < threshold && dz < threshold) {
        // 找到可焊接顶点,记录映射关系
        indexMap.set(vertex.index, i);
        found = true;
        break;
      }
    }
    
    if (!found) {
      // 没有找到则添加新顶点
      indexMap.set(vertex.index, welded.length);
      welded.push(vertex);
    }
  });
  
  return { weldedVertices: welded, indexMap: indexMap };
}
// 执行焊接
const result = weldVertices(vertices);
console.log(`焊接前顶点数:${vertices.length}`); // 输出6
console.log(`焊接后顶点数:${result.weldedVertices.length}`); // 输出4

这段代码的核心是双重循环:外层遍历所有原始顶点,内层检查已焊接顶点中是否有可合并的对象。阈值的设置是关键 ------ 太大会把不同位置的顶点错误合并(比如把鼻子和耳朵焊在一起),太小则无法去除冗余(相当于没磨平的腻子)。

二、法线平滑:给 3D 模型穿上 "丝绸外衣"

从 "积木感" 到 "丝绸感" 的秘密

如果你仔细观察焊接后的立方体,会发现它依然是棱角分明的 ------ 这是因为每个三角形面片都有自己的 "法线"(垂直于面片的方向向量)。就像每个乐高块都有自己的朝向,拼接处会形成明显的光影分界线。法线平滑(Normal Smoothing)通过计算共享顶点的平均法线,让相邻面片的光影过渡变得柔和,就像把积木的棱角磨成圆弧。

法线平滑的数学本质

法线平滑的核心是 "向量平均":对于焊接后共享同一顶点的所有三角形,计算它们法线向量的平均值,作为该顶点的新法线。向量平均不是简单的数值相加,而是要先确保每个法线都是单位向量(长度为 1),再进行分量相加后重新归一化。

这就像一群人朝不同方向拉绳子,平均法线就是他们的 "合力方向"。假设三个三角形的法线分别指向(0,1,0)、(1,0,0)和(0,0,1),那么平均法线就是这三个向量相加后再除以 3,得到一个指向斜上方的新向量。

实现法线平滑的步骤

  1. 计算每个三角形的原始法线
  1. 为每个顶点收集所有共享它的三角形法线
  1. 对收集到的法线进行平均并归一化
  1. 用新法线替换原始法线

JavaScript 实现法线平滑

ini 复制代码
// 假设我们有焊接后的顶点和三角形索引
const triangles = [
  [0, 1, 2], // 三角形1:使用顶点0、1、2
  [0, 2, 3], // 三角形2:使用顶点0、2、3
  [1, 4, 2]  // 三角形3:使用顶点1、4、2
];
// 计算三角形法线的函数
function calculateTriangleNormal(v1, v2, v3) {
  // 计算两条边的向量
  const edge1 = { x: v2.x - v1.x, y: v2.y - v1.y, z: v2.z - v1.z };
  const edge2 = { x: v3.x - v1.x, y: v3.y - v1.y, z: v3.z - v1.z };
  
  // 叉乘计算法线(右手定则)
  const normal = {
    x: edge1.y * edge2.z - edge1.z * edge2.y,
    y: edge1.z * edge2.x - edge1.x * edge2.z,
    z: edge1.x * edge2.y - edge1.y * edge2.x
  };
  
  // 归一化(将向量长度变为1)
  const length = Math.sqrt(normal.x **2 + normal.y** 2 + normal.z **2);
  return {
    x: normal.x / length,
    y: normal.y / length,
    z: normal.z / length
  };
}
// 法线平滑函数
function smoothNormals(weldedVertices, triangles) {
  // 为每个顶点创建法线数组
  const vertexNormals = weldedVertices.map(() => []);
  
  // 计算每个三角形的法线并分配给顶点
  triangles.forEach(tri => {
    const v1 = weldedVertices[tri[0]];
    const v2 = weldedVertices[tri[1]];
    const v3 = weldedVertices[tri[2]];
    const normal = calculateTriangleNormal(v1, v2, v3);
    
    // 将该法线添加到三个顶点的法线列表中
    vertexNormals[tri[0]].push(normal);
    vertexNormals[tri[1]].push(normal);
    vertexNormals[tri[2]].push(normal);
  });
  
  // 计算每个顶点的平均法线
  return weldedVertices.map((v, i) => {
    const normals = vertexNormals[i];
    if (normals.length === 0) return { ...v, normal: { x: 0, y: 0, z: 1 } }; // 默认法线
    
    // 计算法线总和
    const sum = normals.reduce((acc, n) => {
      return {
        x: acc.x + n.x,
        y: acc.y + n.y,
        z: acc.z + n.z
      };
    }, { x: 0, y: 0, z: 0 });
    
    // 归一化平均法线
    const length = Math.sqrt(sum.x **2 + sum.y** 2 + sum.z **2);
    return {
      ...v,
      normal: {
        x: sum.x / length,
        y: sum.y / length,
        z: sum.z / length
      }
    };
  });
}
// 执行法线平滑
const smoothedVertices = smoothNormals(result.weldedVertices, triangles);

这段代码先计算每个三角形的法线(通过叉乘得到垂直向量),再将法线分配给组成三角形的三个顶点。最后对每个顶点收集到的所有法线进行平均和归一化,得到平滑后的法线数据。

三、顶点焊接与法线平滑的协同工作

顶点焊接是法线平滑的前提 ------ 如果顶点没有被合并,每个三角形都会固执地保留自己的法线,无法形成共享顶点的平均计算。就像如果乐高积木没有拼接牢固,你就无法在它们表面顺畅地涂抹腻子。

在实际应用中,这两个步骤通常连续执行:

  1. 先焊接顶点去除冗余,建立共享顶点关系
  1. 再基于共享关系计算平滑法线
  1. 最终渲染时,使用平滑后的法线计算光影效果

游戏引擎中的 "平滑组"(Smoothing Group)功能,本质上是对这一流程的扩展 ------ 它允许艺术家指定哪些区域需要平滑(如角色的手臂),哪些区域需要保留棱角(如机械关节),通过控制哪些顶点参与法线平均来实现局部平滑效果。

四、常见问题与优化技巧

  1. 性能优化:双重循环的顶点焊接在顶点数量庞大时会很慢(时间复杂度 O (n²))。实际项目中会使用空间分区(如网格哈希)将顶点分到不同 "格子" 中,只比较同一格子内的顶点,就像在图书馆找书时先按类别查找,而不是逐本翻阅。
  1. 平滑过度的问题:过度平滑会让模型失去细节(比如把肌肉线条磨成了胖子)。解决方案是设置 "角度阈值",当两个三角形的法线夹角超过阈值时,不进行平均计算(相当于保留棱角)。
  1. 动态网格的处理:对于骨骼动画中的变形网格,需要在动画每一帧重新计算焊接和法线,这时候通常会采用增量更新策略,只处理发生变化的顶点。

从本质上看,顶点焊接和法线平滑是计算机图形学中 "近似计算" 思想的体现 ------ 它们用合理的简化(忽略微小差异、取平均值)换取了视觉效果的巨大提升。就像印象派画家通过模糊笔触表现光影,计算机也通过这些 "不精确" 的计算,为我们创造了逼真的 3D 世界。下次当你在游戏中抚摸角色光滑的皮肤,或是欣赏金属盔甲的柔和反光时,不妨想想这背后是无数顶点和法线在默默 "平均" 出的视觉魔法。

相关推荐
LaoZhangAI4 分钟前
FLUX.1 API图像尺寸设置全指南:优化生成效果与成本
前端·后端
哑巴语天雨12 分钟前
Cesium初探-CallbackProperty
开发语言·前端·javascript·3d
JosieBook30 分钟前
【前端】Vue 3 页面开发标准框架解析:基于实战案例的完整指南
前端·javascript·vue.js
liwei_fang32 分钟前
node.js 调度 --- 事件循环
前端
薄荷椰果抹茶34 分钟前
前端技术之---应用国际化(vue-i18n)
前端·javascript·vue.js
鹿啦啦SHARE35 分钟前
volta了解和使用
前端
小高0071 小时前
掌握 requestFullscreen:网页全屏功能的实用指南与技巧
前端
Kiri霧1 小时前
Kotlin重写函数中的命名参数
android·开发语言·javascript·kotlin
前端付豪1 小时前
22、前端工程化深度实践:构建、发布、CI/CD 流程重构指南
前端·javascript·架构