想象你用乐高积木搭了一座城堡,仔细一看,每块积木的边缘都棱角分明,拼接处能清晰看到缝隙 ------ 这就像计算机里未经处理的 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,得到一个指向斜上方的新向量。
实现法线平滑的步骤
- 计算每个三角形的原始法线
- 为每个顶点收集所有共享它的三角形法线
- 对收集到的法线进行平均并归一化
- 用新法线替换原始法线
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);
这段代码先计算每个三角形的法线(通过叉乘得到垂直向量),再将法线分配给组成三角形的三个顶点。最后对每个顶点收集到的所有法线进行平均和归一化,得到平滑后的法线数据。
三、顶点焊接与法线平滑的协同工作
顶点焊接是法线平滑的前提 ------ 如果顶点没有被合并,每个三角形都会固执地保留自己的法线,无法形成共享顶点的平均计算。就像如果乐高积木没有拼接牢固,你就无法在它们表面顺畅地涂抹腻子。
在实际应用中,这两个步骤通常连续执行:
- 先焊接顶点去除冗余,建立共享顶点关系
- 再基于共享关系计算平滑法线
- 最终渲染时,使用平滑后的法线计算光影效果
游戏引擎中的 "平滑组"(Smoothing Group)功能,本质上是对这一流程的扩展 ------ 它允许艺术家指定哪些区域需要平滑(如角色的手臂),哪些区域需要保留棱角(如机械关节),通过控制哪些顶点参与法线平均来实现局部平滑效果。
四、常见问题与优化技巧
- 性能优化:双重循环的顶点焊接在顶点数量庞大时会很慢(时间复杂度 O (n²))。实际项目中会使用空间分区(如网格哈希)将顶点分到不同 "格子" 中,只比较同一格子内的顶点,就像在图书馆找书时先按类别查找,而不是逐本翻阅。
- 平滑过度的问题:过度平滑会让模型失去细节(比如把肌肉线条磨成了胖子)。解决方案是设置 "角度阈值",当两个三角形的法线夹角超过阈值时,不进行平均计算(相当于保留棱角)。
- 动态网格的处理:对于骨骼动画中的变形网格,需要在动画每一帧重新计算焊接和法线,这时候通常会采用增量更新策略,只处理发生变化的顶点。
从本质上看,顶点焊接和法线平滑是计算机图形学中 "近似计算" 思想的体现 ------ 它们用合理的简化(忽略微小差异、取平均值)换取了视觉效果的巨大提升。就像印象派画家通过模糊笔触表现光影,计算机也通过这些 "不精确" 的计算,为我们创造了逼真的 3D 世界。下次当你在游戏中抚摸角色光滑的皮肤,或是欣赏金属盔甲的柔和反光时,不妨想想这背后是无数顶点和法线在默默 "平均" 出的视觉魔法。