资讯动态

webgl-fundamentals 实战:用贝塞尔曲线与 Lathe 旋转成型在代码中生成 3D 模型

发布时间:2026/10/14 17:31:43 来源:尧图企业网站定制
图形学【免费下载链接】webgl-fundamentalsWebGL 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车削一词的含义就是围绕某个轴旋转点来生成形体。这样就能很容易地做出碗、玻璃杯、棒球棒、瓶子、灯泡这类回转体。所以整体流程分三步原文档脉络下文逐节展开先会画曲线贝塞尔曲线并计算曲线上的点用矩阵数学把这些点绕轴旋转旋转出的各圈点之间用三角面连接。三次贝塞尔曲线定义、插值与简化公式计算机图形里最常见的曲线就是贝塞尔曲线。只要你在 Adobe Illustrator、Inkscape、Affinity Designer 之类的矢量软件里编辑过曲线你画的就是贝塞尔曲线原文档致谢中也提到作者的实现参考了 Pomax 关于贝塞尔曲线的经典解析文章。三次贝塞尔曲线由 4 个点组成其中 2 个是端点P1、P4另外 2 个是控制点P2、P3。de Casteljau 插值过程设t是 0起点到 1终点之间的参数先计算相邻点之间的插值点在P1→P2、P2→P3、P3→P4三条线段上各取比例为t的点得到Q1、Q2、Q3。例如t .25时就是各线段 25% 处的点对结果再做同样操作计算Q1→Q2、Q2→Q3上的t点得到R1、R2最后计算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 判断与自适应细分画出一串点有一个关键问题取多少个点才够曲线弯曲剧烈的地方需要更多点几乎成直线的地方则很少。可行的策略是检查曲线有多弯弯得过头就把它拆成两条曲线继续递归。而拆分恰好是免费的——回看上面的插值过程可以发现对同一个tP1、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 算法来压缩点集拿一段点列表找离两端连线最远的点如果该点离这条线的距离小于阈值epsilon就只保留两个端点、丢弃中间所有点否则以该最远点为界对起点→最远点和最远点→终点两段各递归执行一次。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 fillnone stroke-width5 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 轴上x0, z0额外插入一个顶点索引生成自然会把侧面三角形收拢到轴上的点形成锥面封口。u沿圆周方向division / numDivisionsv沿轮廓方向。第一版实现里v直接用点的索引归一化(ndx vOffset) / quadsDown源码注释也诚实标注了问题webgl-3d-lathe-step-01.html 中写着// note: this V is wrong. Its spacing by ndx instead of distance along curve。矩阵m4.yRotation(angle)与m4.transformPoint都来自 webgl/resources/3d-math.jsyRotation定义于 L633transformPoint定义于 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); }算法分三步面法线对每个三角形用normalize(cross(v1 - v2, v3 - v2))算出法线m4工具见 3d-math.js识别共享顶点以位置三元组字符串为键建哈希表。注意源码注释特别强调的坑圆柱首尾两圈顶点位置相同但 UV 不同u0vsu1按位置匹配时仍要视为共享否则接缝处法线会出错平均化 重建对每个顶点的每个面把与当前面点积大于maxAngleCos的面法线累加、归一化再以位置 法线 UV为键生成新顶点集并输出新索引。maxAngle控制软硬边过渡maxAngle越大越多相邻面被纳入平均边缘越柔和。step-03 示例 中generateMesh的调用链为getPointsOnBezierCurves → simplifyPoints → lathePoints → generateNormalsL180-L184并提供三种显示模式mode下拉框L803normals片元着色器直接输出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 最终版L575for (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);取舍取决于你的需求先选灵活方案等真慢了再优化。总结学到了什么流程本身2D 剪影曲线 → 贝塞尔采样flatness 自适应细分→ RDP 简化 → 按弧长修正 UV → 绕轴旋转生成位置/UV/索引 → 面法线平均化maxAngle软硬边→ WebGL 缓冲区。这条管线就是程序化生成回转体模型的全部骨架三个示例文件 step-01、step-02、step-03 分别对应基础旋转、UV 修正、法线计算三个递进里程碑。诚实的结论想要 3D 数据直接用 3D 建模软件作者原话带笑。原文档开宗明义就声明这篇文章并非鼓励你手写建模管线而是借这个主题帮助你理解WebGL 3D 模型是怎么做出来的。工具的价值真正做出有用的东西你需要真正的 UV 编辑器处理 cap 的 UV、以及建模软件里的Extrude Face面挤出与Extrude Along Path沿路径挤出等能力——对照本文的 lathe 示例就能理解它们的运作原理。赞分享图形学【免费下载链接】webgl-fundamentalsWebGL lessons that start with the basics项目地址https://gitcode.com/gh_mirrors/we/webgl-fundamentals点击查看免费下载相关推荐BezierMaker动态贝塞尔曲线生成器BezierMaker动态贝塞尔曲线生成器 BezierMaker是一个功能强大的开源Android应用专门用于创建和探索从简单到复杂的贝塞尔曲线。通过先进fontTools misc.symfont 模块解析用 SymPy 符号数学为贝塞尔曲线生成字体统计代码fontTools misc.symfont 模块解析用 SymPy 符号数学为贝塞尔曲线生成字体统计代码 fontTools.misc.symfont 是开发工具Leon Sans中的数学曲线贝塞尔曲线应用Leon Sans中的数学曲线贝塞尔曲线应用 你是否曾好奇那些流畅优雅的字体轮廓是如何通过代码实现的在Leon Sans字体引擎中贝塞尔曲线Bezier图形学创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑