☰
webgl-fundamentals 实战:用贝塞尔曲线与 Lathe 旋转成型在代码中生成 3D 模型
2026/10/14 17:31:37 网站建设 项目流程
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

本文基于 webgl-fundamentals 仓库中的教程文档,完整讲解"Lathe(车削旋转成型)"这一程序化建模技术:如何把一条二维贝塞尔曲线轮廓采样成点集、按曲线长度修正 UV、围绕 Y 轴旋转生成三角网格,并计算带maxAngle阈值的平滑法线。读完本文,你将掌握一套完全用 JavaScript 代码生成 3D 模型数据的流程,并能在仓库中直接运行 webgl-3d-lathe-step-01.html、webgl-3d-lathe-step-02.html、webgl-3d-lathe-step-03.html 三个递进式交互示例来验证每个阶段的效果。

为什么要在代码里"捏"一个模型

原文档的出发点是一个真实提问:有人问"如何在 WebGL 里做一个保龄球瓶形状"。

标准回答是:使用 Blender、Maya、3D Studio Max、Cinema 4D 等 3D 建模软件建模,导出后加载——OBJ 格式就比较容易解析(本仓库也有专门讲解 加载 OBJ 模型的教程)。

但如果你自己就是要做一个建模软件呢?那就必须理解模型数据是如何从二维轮廓"长"出来的。

Lathe(车削/旋转成型)的思路

  • 一种朴素做法是用标准圆柱体叠加正弦波变形来近似,但问题是拿不到光滑的曲面:标准圆柱是等间距的一圈圈环(ring)组成的,弯曲越复杂的地方就越需要更多的环。
  • 建模软件的真正做法是:先画出物体一侧的2D 剪影曲线,然后把这条曲线围绕某个轴旋转,扫出三维表面。"Lathe(车削)"一词的含义就是"围绕某个轴旋转点来生成形体"。这样就能很容易地做出碗、玻璃杯、棒球棒、瓶子、灯泡这类回转体。

所以整体流程分三步(原文档脉络,下文逐节展开):

  1. 先会画曲线(贝塞尔曲线),并计算曲线上的点;
  2. 用矩阵数学把这些点绕轴旋转,旋转出的各圈点之间用三角面连接。

三次贝塞尔曲线:定义、插值与简化公式

计算机图形里最常见的曲线就是贝塞尔曲线。只要你在 Adobe Illustrator、Inkscape、Affinity Designer 之类的矢量软件里编辑过曲线,你画的就是贝塞尔曲线(原文档致谢中也提到,作者的实现参考了 Pomax 关于贝塞尔曲线的经典解析文章)。

三次贝塞尔曲线由 4 个点组成:其中 2 个是端点(P1、P4),另外 2 个是"控制点"(P2、P3)。

de Casteljau 插值过程

设t是 0(起点)到 1(终点)之间的参数:

  1. 先计算相邻点之间的插值点:在P1→P2、P2→P3、P3→P4三条线段上各取比例为t的点,得到Q1、Q2、Q3。例如t = .25时就是各线段 25% 处的点;
  2. 对结果再做同样操作:计算Q1→Q2、Q2→Q3上的t点,得到R1、R2;
  3. 最后计算R1→R2之间的t点。

最终这个红点的位置就是曲线上的点。当t从 0 扫到 1,红点描出的轨迹即整条曲线。仓库中有一个可交互的示意图 bezier-curve-diagram.html,可以拖动t滑块、拖动P1~P4,并逐级查看上述插值层次;另一个交互编辑器 bezier-curve-edit.html 则演示了曲线编辑的行为。

矢量软件中"长曲线"的本质

在 2D 矢量软件里画出的一条长曲线,实际上是由多段 4 点的小曲线串联而成的:

  • P1,P2,P3,P4构成的曲线与P4,P5,P6,P7构成的曲线是两条独立的曲线,只是在共享点P4处相接;
  • 当P3和P5恰好位于P4两侧的正反方向(共线反向)时,两条曲线"看起来"是一条连续的曲线。绝大多数应用默认把控制点这样"固定(pin)":拖动P3时P5会跟着反向移动。很多应用还提供取消固定的选项——取消固定后拖动P3或P5,就能明显看出它们是两条独立曲线。

简化后的求值公式

上面"4 个变 3 个、3 个变 2 个、2 个变 1 个"的逐层插值是几何直觉,不是高效算法。有人把所有插值公式联立化简,得到直接求值公式:

invT = (1 - t) P = P1 * invT^3 + P2 * 3 * t * invT^2 + P3 * 3 * invT * t^2 + P4 * t^3

其中P1~P4是 4 个点,P是曲线上t处的点。对应的 JavaScript 实现(与示例源码一致):

function getPointOnBezierCurve(points, offset, t) { const invT = (1 - t); return v2.add(v2.mult(points[offset + 0], invT * invT * invT), v2.mult(points[offset + 1], 3 * t * invT * invT), v2.mult(points[offset + 2], 3 * invT * t * t), v2.mult(points[offset + 3], t * t *t)); }

再套一层循环就能得到曲线上一整串点:

function getPointsOnBezierCurve(points, offset, numPoints) { const cpoints = []; for (let i = 0; i < numPoints; ++i) { const t = i / (numPoints - 1); cpoints.push(getPointOnBezierCurve(points, offset, t)); } return cpoints; }

注:v2.mult和v2.add是对点做乘/加运算的小工具函数。这三个示例文件都内嵌了一个v2模块(例如 webgl-3d-lathe-step-01.html 底部的const v2 = (function() { ... })()),提供add、sub、mult、lerp、distance、distanceSq、distanceToSegmentSq等方法,可直接在浏览器里查看。

采样多少个点?flatness 判断与自适应细分

"画出一串点"有一个关键问题:取多少个点才够?曲线弯曲剧烈的地方需要更多点,几乎成直线的地方则很少。

可行的策略是:检查曲线有多"弯",弯得过头就把它拆成两条曲线继续递归。而拆分恰好是免费的——回看上面的插值过程可以发现:对同一个t,P1、Q1、R1和曲线上红点这 4 个点构成一条曲线;红点、R2、Q3、P4构成另一条曲线。也就是说,曲线可以在任意处拆成两条与原曲线完全一致的子曲线。

判断"弯不弯"用经典的 flatness(平坦度)近似函数:

function flatness(points, offset) { const p1 = points[offset + 0]; const p2 = points[offset + 1]; const p3 = points[offset + 2]; const p4 = points[offset + 3]; let ux = 3 * p2[0] - 2 * p1[0] - p4[0]; ux *= ux; let uy = 3 * p2[1] - 2 * p1[1] - p4[1]; uy *= uy; let vx = 3 * p3[0] - 2 * p4[0] - p1[0]; vx *= vx; let vy = 3 * p3[1] - 2 * p4[1] - p1[1]; vy *= vy; if(ux < vx) { ux = vx; } if(uy < vy) { uy = vy; } return ux + uy; }

把 flatness 接进采样函数:平坦(低于容差tolerance)就只保留端点,否则在t = .5处中点细分后递归处理两半:

function getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints) { const outPoints = newPoints || []; if (flatness(points, offset) < tolerance) { // 只加这条曲线的端点 outPoints.push(points[offset + 0]); outPoints.push(points[offset + 3]); } else { // 细分 const t = .5; const p1 = points[offset + 0]; const p2 = points[offset + 1]; const p3 = points[offset + 2]; const p4 = points[offset + 3]; const q1 = v2.lerp(p1, p2, t); const q2 = v2.lerp(p2, p3, t); const q3 = v2.lerp(p3, p4, t); const r1 = v2.lerp(q1, q2, t); const r2 = v2.lerp(q2, q3, t); const red = v2.lerp(r1, r2, t); // 处理前半段 getPointsOnBezierCurveWithSplitting([p1, q1, r1, red], 0, tolerance, outPoints); // 处理后半段 getPointsOnBezierCurveWithSplitting([red, r2, q3, p4], 0, tolerance, outPoints); } return outPoints; }

这个算法擅长保证"点够不够多",但不太擅长去掉多余点——于是引出下一节。

用 Ramer–Douglas–Peucker 算法简化点集

原文档介绍了 RDP 算法来压缩点集:

  1. 拿一段点列表,找离两端连线最远的点;
  2. 如果该点离这条线的距离小于阈值epsilon,就只保留两个端点、丢弃中间所有点;
  3. 否则以该最远点为界,对"起点→最远点"和"最远点→终点"两段各递归执行一次。
function simplifyPoints(points, start, end, epsilon, newPoints) { const outPoints = newPoints || []; // 找离两端连线最远的点 const s = points[start]; const e = points[end - 1]; let maxDistSq = 0; let maxNdx = 1; for (let i = start + 1; i < end - 1; ++i) { const distSq = v2.distanceToSegmentSq(points[i], s, e); if (distSq > maxDistSq) { maxDistSq = distSq; maxNdx = i; } } // 如果该点离得"太远了" if (Math.sqrt(maxDistSq) > epsilon) { // 拆分 simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints); simplifyPoints(points, maxNdx, end, epsilon, outPoints); } else { // 加两个端点 outPoints.push(s, e); } return outPoints; }

v2.distanceToSegmentSq计算点到线段的距离平方——比真实距离省一次开方;而本算法只需要比较"谁最远",距离平方与距离的排序完全一致,所以放心用。

从 SVG 剪影获取保龄球瓶轮廓

把上面所有代码扩展成一个完整的曲线编辑器并不容易(要支持增删点、固定/解除控制点、撤销……),所以原文档选择了更省事的路径:直接用现成的矢量编辑器画剪影。作者使用了开源的 SVG-edit 在线编辑器,产出的保龄球瓶剪影就是仓库里的 bowling-pin-silhouette.svg,它由4 条贝塞尔曲线构成:

<path fill="none" stroke-width="5" d=" m44,434 c18,-33 19,-66 15,-111 c-4,-45 -37,-104 -39,-132 c-2,-28 11,-51 16,-81 c5,-30 3,-63 -36,-63 "/>

按 SVG path 语法规则(m移动、c三次贝塞尔,小写字母为相对坐标,每段c后跟 3 组控制/端点坐标)解读,会得到 4 段曲线共 12 个点:

___ 44, 371, | 62, 338, | 第 1 段曲线 63, 305,___|__ 59, 260,___| | 55, 215, | 第 2 段曲线 22, 156,______|__ 20, 128,______| | 18, 100, | 第 3 段曲线 31, 77,_________|__ 36, 47,_________| | 41, 17, | 第 4 段曲线 39, -16, | 0, -16,____________|

(原文档的图示中各点为经过变换后的坐标示意,核心信息是:每条c命令展开为 4 个点——起点 + 2 个控制点 + 端点,相邻段共享端点。)

对所有曲线段统一采样

// 从所有段中取点 function getPointsOnBezierCurves(points, tolerance) { const newPoints = []; const numSegments = (points.length - 1) / 3; for (let i = 0; i < numSegments; ++i) { const offset = i * 3; getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints); } return newPoints; }

随后对结果调用simplifyPoints压缩。

源码印证:webgl-3d-lathe-step-01.html 里直接把这段 SVG path 存为字符串常量const svg = "m44,434c18,-33 19,-66 15,-111...",并在 parseSVGPath 中实现了一个逐字符的状态机解析器:识别m/M/c/C指令、区分相对坐标(小写时累加上一个点)与绝对坐标(大写),解析完再平移归一化到合适的坐标范围。step-03的解析器还扩展了l/L直线段——遇到直线段时会把两端点用 25%/75% 插值点"伪装"成一条退化的贝塞尔段,统一走后续的贝塞尔管线(见 webgl-3d-lathe-step-03.html)。

Lathe 旋转成型:lathePoints 完整实现

拿到轮廓点后,剩下就是把它绕轴转起来。决定圆周方向分成多少份(numDivisions),对每个角度用矩阵数学把轮廓点绕 Y 轴旋转,然后把各圈点用索引连成三角形:

// 绕 Y 轴旋转 function lathePoints(points, startAngle, // 起始角度 (如 0) endAngle, // 结束角度 (如 Math.PI * 2) numDivisions, // 圆周方向生成多少个四边形 capStart, // true 时封闭起始端 capEnd) { // true 时封闭结束端 const positions = []; const texcoords = []; const indices = []; const vOffset = capStart ? 1 : 0; const pointsPerColumn = points.length + vOffset + (capEnd ? 1 : 0); const quadsDown = pointsPerColumn - 1; // 生成点 for (let division = 0; division <= numDivisions; ++division) { const u = division / numDivisions; const angle = lerp(startAngle, endAngle, u); const mat = m4.yRotation(angle); if (capStart) { // 在起始端往 Y 轴上加点 positions.push(0, points[0][1], 0); texcoords.push(u, 0); } points.forEach((p, ndx) => { const tp = m4.transformPoint(mat, [...p, 0]); positions.push(tp[0], tp[1], tp[2]); const v = (ndx + vOffset) / quadsDown; texcoords.push(u, v); }); if (capEnd) { // 在结束端往 Y 轴上加点 positions.push(0, points[points.length - 1][1], 0); texcoords.push(u, 1); } } // 生成索引 for (let division = 0; division < numDivisions; ++division) { const column1Offset = division * pointsPerColumn; const column2Offset = column1Offset + pointsPerColumn; for (let quad = 0; quad < quadsDown; ++quad) { indices.push(column1Offset + quad, column1Offset + quad + 1, column2Offset + quad); indices.push(column1Offset + quad + 1, column2Offset + quad + 1, column2Offset + quad); } } return { position: positions, texcoord: texcoords, indices: indices, }; }

几个要点:

  • capStart/capEnd:想象你在做一个易拉罐——这两个开关决定两端要不要封盖。封盖的实现很简单:每一列都在 Y 轴上(x=0, z=0)额外插入一个顶点,索引生成自然会把侧面三角形"收拢"到轴上的点,形成锥面封口。
  • u沿圆周方向(division / numDivisions),v沿轮廓方向。第一版实现里v直接用点的索引归一化((ndx + vOffset) / quadsDown),源码注释也诚实标注了问题:webgl-3d-lathe-step-01.html 中写着// note: this V is wrong. It's spacing by ndx instead of distance along curve。
  • 矩阵m4.yRotation(angle)与m4.transformPoint都来自 webgl/resources/3d-math.js(yRotation定义于 L633,transformPoint定义于 L1096)。

把数据送入 WebGL 缓冲区

用仓库的精简工具代码(webgl-utils) 一行创建缓冲区,示例参数与 webgl-3d-lathe-step-01.html 中的generateMesh完全一致:

const tolerance = 0.15; const distance = .4; const divisions = 16; const startAngle = 0; const endAngle = Math.PI * 2; const capStart = true; const capEnd = true; const tempPoints = getPointsOnBezierCurves(curvePoints, tolerance); const points = simplifyPoints(tempPoints, 0, tempPoints.length, distance); const arrays = lathePoints(points, startAngle, endAngle, divisions, capStart, capEnd); const extents = getExtents(arrays.position); if (!bufferInfo) { bufferInfo = webglUtils.createBufferInfoFromArrays(gl, arrays);

示例的交互面板(由webglLessonsUI.setupUI生成,见 step-01 源码)把每个参数都暴露成了可调项:

参数类型范围/默认作用
distance滑块0.001 ~ 5,默认 0.4RDP 简化的 epsilon,越小轮廓点越多
divisions滑块1 ~ 60,默认 16圆周方向的四边形数量
startAngle/endAngle滑块0 ~ 2π(UI 显示为 0~360 度)旋转起止角度,缩小范围可做"半瓶"
capStart/capEnd复选框默认勾选两端是否封盖
triangles复选框—开关三角形绘制(关掉则显示gl.LINES线框,方便观察网格)

运行 step-01 示例,拖动滑块即可直观看到每个参数对模型的影响。

修正 UV:让 v 坐标按曲线长度分布

第一版的问题在于:开启三角形后可以看到纹理分布不均匀——因为v坐标是按点在数组里的索引计算的。如果各点恰好等距,这样做没问题;但自适应细分 + RDP 简化后的点并不是等距的,弯曲处密、平缓处疏,纹理网格就会跟着歪斜。

修复方法:遍历点计算曲线总长和每个点沿曲线的累积长度,再用总长归一化,得到更合理的v:

// 绕 Y 轴旋转 function lathePoints(points, startAngle, // 起始角度 endAngle, // 结束角度 numDivisions, // 圆周方向四边形数量 capStart, // true 封闭上端 capEnd) { // true 封闭下端 const positions = []; const texcoords = []; const indices = []; const vOffset = capStart ? 1 : 0; const pointsPerColumn = points.length + vOffset + (capEnd ? 1 : 0); const quadsDown = pointsPerColumn - 1; + // 生成 v 坐标 + let vcoords = []; + + // 先计算各点沿曲线的长度 + let length = 0; + for (let i = 0; i < points.length - 1; ++i) { + vcoords.push(length); + length += v2.distance(points[i], points[i + 1]); + } + vcoords.push(length); // 最后一个点 + + // 再除以总长做归一化 + vcoords = vcoords.map(v => v / length); // 生成点 for (let division = 0; division <= numDivisions; ++division) { const u = division / numDivisions; const angle = lerp(startAngle, endAngle, u); const mat = m4.yRotation(angle); if (capStart) { positions.push(0, points[0][1], 0); texcoords.push(u, 0); } points.forEach((p, ndx) => { const tp = m4.transformPoint(mat, [...p, 0]); positions.push(tp[0], tp[1], tp[2]); - texcoords.push(u, (ndx + vOffset) / quadsDown); + texcoords.push(u, vcoords[ndx]); }); ... } ... }

这正是 webgl-3d-lathe-step-02.html 的实现(vcoords 计算见 L407-L420),效果是 UV 网格沿曲面均匀分布。

不过这些 UV 仍不完美:封盖(cap)的 UV 该怎么排还没有解决。原文档点明——"barrel UV mapping(桶形 UV 映射)"这类问题,获得完美 UV 根本不是单纯的数学题,而是一个数据输入问题,需要好的 UV 编辑工具。这也再次说明为什么建模软件不可少。

计算法线:从面法线到 maxAngle 平滑

光栅化着色需要法线。对回转体而言,理论上可以走捷径:曲线每个点的法线垂直于切线,而从 de Casteljau 过程看,R1→R2那条线段正好就是曲线在红点处的切线方向,所以可以沿轮廓解析地求法线。

但换成 candle-holder.svg 这类剪影(step-03 示例实际使用的轮廓),曲面平滑处与棱角处混杂,就没有捷径了。更麻烦的是:尖锐边缘需要额外顶点——因为顶点是"位置 + 法线"的组合,同一位置若要两种法线,就必须拆成两个顶点。这正是立方体虽然只有 8 个几何角点、却至少需要24 个顶点的原因:每个面在共享棱上都需要各自不同的法线。

建模软件的标准做法是:对每个顶点,平均化所有共享该顶点的多边形面法线;但允许用户指定一个最大角度maxAngle——相邻面夹角超过它就不再平均,从而在需要的地方保留硬边、在平滑的地方得到柔和过渡。下面就是原文档给出的完整实现(与 step-03 源码 一致):

function generateNormals(arrays, maxAngle) { const positions = arrays.position; const texcoords = arrays.texcoord; // 先计算每个面的法线 let getNextIndex = makeIndiceIterator(arrays); const numFaceVerts = getNextIndex.numElements; const numVerts = arrays.position.length; const numFaces = numFaceVerts / 3; const faceNormals = []; // 计算所有面法线, // 同时为每个面的每个顶点建一个新顶点 for (let i = 0; i < numFaces; ++i) { const n1 = getNextIndex() * 3; const n2 = getNextIndex() * 3; const n3 = getNextIndex() * 3; const v1 = positions.slice(n1, n1 + 3); const v2 = positions.slice(n2, n2 + 3); const v3 = positions.slice(n3, n3 + 3); faceNormals.push(m4.normalize(m4.cross(m4.subtractVectors(v1, v2), m4.subtractVectors(v3, v2)))); } let tempVerts = {}; let tempVertNdx = 0; // 注意:这里假设顶点位置是精确相等的 function getVertIndex(x, y, z) { const vertId = x + "," + y + "," + z; const ndx = tempVerts[vertId]; if (ndx !== undefined) { return ndx; } const newNdx = tempVertNdx++; tempVerts[vertId] = newNdx; return newNdx; } // 需要弄清哪些顶点是共享的。 // 这不是看面(三角形)那么简单,例如一个标准圆柱: // // 3-4 // / \ // 2 5 从上往下俯视圆柱,S 是起点绕到 E 的终点, // | | E 和 S 的 UV 坐标不同, // 1 6 所以在我们的数据里不是同一个顶点。 // \ / // S/E // // 首尾两圈的顶点因 UV 不同而不共享, // 但若不算它们共享,法线就会算错。 const vertIndices = []; for (let i = 0; i < numVerts; ++i) { const offset = i * 3; const vert = positions.slice(offset, offset + 3); vertIndices.push(getVertIndex(vert)); } // 遍历所有顶点,记录它出现在哪些面上 const vertFaces = []; getNextIndex.reset(); for (let i = 0; i < numFaces; ++i) { for (let j = 0; j < 3; ++j) { const ndx = getNextIndex(); const sharedNdx = vertIndices[ndx]; let faces = vertFaces[sharedNdx]; if (!faces) { faces = []; vertFaces[sharedNdx] = faces; } faces.push(i); } } // 然后遍历每个面,为面上每个顶点计算法线: // 只把与当前面夹角不超过 maxAngle 的面纳入平均, // 结果写入 newPositions/newTexcoords/newNormals, // 完全相同的顶点会被合并丢弃。 tempVerts = {}; tempVertNdx = 0; const newPositions = []; const newTexcoords = []; const newNormals = []; function getNewVertIndex(x, y, z, nx, ny, nz, u, v) { const vertId = x + "," + y + "," + z + "," + nx + "," + ny + "," + nz + "," + u + "," + v; const ndx = tempVerts[vertId]; if (ndx !== undefined) { return ndx; } const newNdx = tempVertNdx++; tempVerts[vertId] = newNdx; newPositions.push(x, y, z); newNormals.push(nx, ny, nz); newTexcoords.push(u, v); return newNdx; } const newVertIndices = []; getNextIndex.reset(); const maxAngleCos = Math.cos(maxAngle); // 对每个面 for (let i = 0; i < numFaces; ++i) { // 取当前面的法线 const thisFaceNormal = faceNormals[i]; // 对面上每个顶点 for (let j = 0; j < 3; ++j) { const ndx = getNextIndex(); const sharedNdx = vertIndices[ndx]; const faces = vertFaces[sharedNdx]; const norm = [0, 0, 0]; faces.forEach(faceNdx => { // 该面是否朝同一方向(夹角足够小) const otherFaceNormal = faceNormals[faceNdx]; const dot = m4.dot(thisFaceNormal, otherFaceNormal); if (dot > maxAngleCos) { m4.addVectors(norm, otherFaceNormal, norm); } }); m4.normalize(norm, norm); const poffset = ndx * 3; const toffset = ndx * 2; newVertIndices.push(getNewVertIndex( positions[poffset + 0], positions[poffset + 1], positions[poffset + 2], norm[0], norm[1], norm[2], texcoords[toffset + 0], texcoords[toffset + 1])); } } return { position: newPositions, texcoord: newTexcoords, normal: newNormals, indices: newVertIndices, }; } function makeIndexedIndicesFn(arrays) { const indices = arrays.indices; let ndx = 0; const fn = function() { return indices[ndx++]; }; fn.reset = function() { ndx = 0; }; fn.numElements = indices.length; return fn; } function makeUnindexedIndicesFn(arrays) { let ndx = 0; const fn = function() { return ndx++; }; fn.reset = function() { ndx = 0; } fn.numElements = arrays.positions.length / 3; return fn; } function makeIndiceIterator(arrays) { return arrays.indices ? makeIndexedIndicesFn(arrays) : makeUnindexedIndicesFn(arrays); }

算法分三步:

  1. 面法线:对每个三角形用normalize(cross(v1 - v2, v3 - v2))算出法线(m4工具见 3d-math.js);
  2. 识别共享顶点:以位置三元组字符串为键建哈希表。注意源码注释特别强调的坑:圆柱首尾两圈顶点位置相同但 UV 不同(u=0vsu=1),按位置匹配时仍要视为共享,否则接缝处法线会出错;
  3. 平均化 + 重建:对每个顶点的每个面,把与当前面点积大于maxAngleCos的面法线累加、归一化,再以"位置 + 法线 + UV"为键生成新顶点集并输出新索引。maxAngle控制软硬边过渡:maxAngle越大,越多相邻面被纳入平均,边缘越柔和。

step-03 示例 中generateMesh的调用链为getPointsOnBezierCurves → simplifyPoints → lathePoints → generateNormals(L180-L184),并提供三种显示模式(mode下拉框,L803):

  • normals:片元着色器直接输出vec4(v_normal * .5 + .5, 1)把法线可视化成颜色;
  • lit:完整 Blinn-Phong 风格着色(顶点着色器里用mat3(u_worldInverseTranspose) * a_normal把法线变换到世界空间,片元着色器做pow(dot(normal, halfVector), u_shininess)高光),可以验证"为什么需要法线";
  • texcoords:输出 UV 贴图,检验第二节的 UV 修正。

maxAngle默认degToRad(30)。原文档的调参建议:把divisions调到 5 或 6 这种低值,再调maxAngle,就能看出"周边边缘保持硬边、而希望平滑的地方柔和"的选择效果;切到lit模式可直观看到光照表现。

深入解析一:那个取余运算符是干什么的

lathePoints里有一行不起眼的代码(step-03 最终版,L575):

for (let division = 0; division <= numDivisions; ++division) { const u = division / numDivisions; const angle = lerp(startAngle, endAngle, u) % (Math.PI * 2); }

这个% (Math.PI * 2)为什么存在?

  • 动机:绕圆旋转时,我们希望第一列和最后一列的点完全重合。Math.sin(0)与Math.sin(Math.PI * 2)理论上应相等,但浮点运算不完美——两者"足够接近"却通常不 100% 相等;
  • 后果:法线计算靠比较顶点坐标来找共享顶点(getVertIndex用字符串键)。首尾两圈点不完全相等,就不会被判定为同一顶点,于是该处参与平均的面缺失,法线算错,模型表面出现一条接缝(原文档附了接缝效果图 lathe-normal-seam.png:接缝出现在本应共享顶点的位置);
  • 失败的修法:作者先试了"epsilon 容差匹配"——判断两点是否在极小距离内就视为同一顶点,效果确实消除了接缝,但这是O(n²)算法:把distance调低、divisions调高,点数量可到 114000 个,O(n²) 意味着接近12 万亿次迭代,界面直接卡死。作者也考虑过把所有点放进八叉树加速查找,但超出本文篇幅;
  • 最终修法:既然只有端点是问题,直接对角度取余——
// 原代码 const angle = lerp(startAngle, endAngle, u); // 新代码 const angle = lerp(startAngle, endAngle, u) % (Math.PI * 2);

当endAngle为Math.PI * 2时,最后一圈的angle取余后恰好是0,与startAngle完全一致,接缝消失。

性能附注:即便修复后,把distance设为 0.001、divisions设为 60 时,重新计算网格仍要将近 1 秒。作者的结论是:生成复杂网格本来就是 CPU 上的慢操作,这正是"3D 游戏能跑 60fps、而 3D 建模软件经常帧率很低"的根本原因。

深入解析二:矩阵数学在这里是杀鸡用牛刀吗

旋转轮廓点的原始写法:

const mat = m4.yRotation(angle); ... points.forEach((p, ndx) => { const tp = m4.transformPoint(mat, [...p, 0]); ... });

用 4x4 矩阵变换一个 3D 点需要16 次乘法 + 12 次加法 + 3 次除法。而绕 Y 轴旋转其实可以直接用单位圆风格的旋转公式展开:

const s = Math.sin(angle); const c = Math.cos(angle); ... points.forEach((p, ndx) => { const x = p[0]; const y = p[1]; const z = p[2]; const tp = [ x * c - z * s, y, x * s + z * c, ]; ... });

后者只有4 次乘法 + 2 次加法,且没有函数调用开销,至少快 4 倍。

那么值得优化吗?作者的看法:就这个例子而言工作量还不足以让优化变得重要;但他更看重矩阵写法的灵活性——如果希望用户能自己选旋转轴,用矩阵一行就够(axisRotation在 3d-math.js 中实现):

const mat = m4.axisRotation(userSuppliedAxis, angle);

取舍取决于你的需求:先选灵活方案,等真慢了再优化。

总结:学到了什么

  1. 流程本身:2D 剪影曲线 → 贝塞尔采样(flatness 自适应细分)→ RDP 简化 → 按弧长修正 UV → 绕轴旋转生成位置/UV/索引 → 面法线平均化(maxAngle软硬边)→ WebGL 缓冲区。这条管线就是程序化生成回转体模型的全部骨架,三个示例文件 step-01、step-02、step-03 分别对应"基础旋转"、"UV 修正"、"法线计算"三个递进里程碑。
  2. 诚实的结论:想要 3D 数据?直接用 3D 建模软件(作者原话,带笑)。原文档开宗明义就声明:这篇文章并非鼓励你手写建模管线,而是借这个主题帮助你理解"WebGL 3D 模型是怎么做出来的"。
  3. 工具的价值:真正做出有用的东西,你需要真正的 UV 编辑器(处理 cap 的 UV)、以及建模软件里的Extrude Face(面挤出)与Extrude Along Path(沿路径挤出)等能力——对照本文的 lathe 示例就能理解它们的运作原理。
  • 图形学

【免费下载链接】webgl-fundamentals

WebGL lessons that start with the basics

项目地址:https://gitcode.com/gh_mirrors/we/webgl-fundamentals
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询