- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
圆环面(Torus)是 WebGL 三维渲染中最常用的基础几何体之一,但手动生成它的顶点、法线与索引时容易踩坑。本文以 webgl-fundamentals 仓库中的 Q&A 实战问答《How to create a torus》为蓝本,结合 完整示例 与仓库内置的 twgl 库源码,系统讲解圆环面的参数方程、正确法线计算、环绕式顶点布局与三角形索引拼接,帮助你理解并写出可直接运行的圆环面生成代码。
圆环面的几何定义与参数方程
圆环面可以看作一根"管道"(截面为圆形)绕空间中一条轴线旋转一周得到的曲面。它由两个半径决定:
outerRad(R):圆环中心线(旋转轴到管道中心的距离)的半径,即从圆环内部空洞中心到管道截面圆心的距离;inner_rad(r):管道截面的半径,即管道本身的粗细。
在标准参数化表示下,圆环面上任意一点的坐标由两个角度v与w决定,这正是原文档中给出的公式:
x = (R + r·cos(v)) · cos(w) y = (R + r·cos(v)) · sin(w) z = r·sin(v)其中:
v沿管道截面圆周方向变化(绕管道自身一圈),范围[0, 2π),决定"管道截面上的位置";w沿圆环旋转方向变化(绕主轴一圈),范围[0, 2π),决定"管道绕主轴转到哪个位置"。
理解两个角度的角色是后续正确嵌套循环的前提:外层循环遍历截面方向v(切片 slice),内层循环遍历绕主轴方向w(环 loop)。这一划分方式与仓库中 车床几何体(Lathe)教程 的思想一致——圆环面本质上就是将一个圆截面绕轴旋转 360° 得到的旋转体。
原始实现中的四个典型错误
原文档中的提问者给出了一段"看似能跑"的生成代码,但它隐藏了四个常见错误,值得逐一剖析:
错误一:内层循环里计算公式没有变化
原始代码把"切片角度"和"环角度"混在一起用两个while循环推进,内层循环只改变loops_angle(即w),但顶点坐标公式中同时依赖cos_slices/sin_slices与cos_loops/sin_loops。由于cos_slices、sin_slices只在外层循环更新,内层循环实际上只是在绕主轴方向连续采样,这本身没有错;真正的隐患在于角度累加式的循环难以保证边界精确闭合(浮点累加误差会导致接缝处出现裂缝),而且代码把"切片/环"的角色安排得反直觉,索引生成时极易出错。
错误二:法线被错误地赋成了顶点位置
原始代码中this.normals.push(x, y, z)把顶点坐标直接当作法线。只有当圆环面是以原点为球心的球体时,位置向量方向才恰好等于法线方向;圆环面的法线方向指向管道截面圆心,与位置向量并不重合。原文档明确指出了这一点:"The normals can't be x, y, z (the same as the positions)."
错误三:缺少slices + 1/loops + 1个环绕闭合顶点
原文档指出:"you need to make loops + 1 and slices + 1 vertices. The vertices start vertex of each loop, slice will have the same position but it will not have the same texture coordinate."(需要生成loops + 1和slices + 1个顶点;每一圈起始顶点与末尾顶点位置相同,但纹理坐标不同。)
这是因为圆环是闭合曲面:绕满一圈后必须回到起点,才能在0与2π的接缝处用独立的顶点承载不同的 UV 坐标。如果只生成slices × loops个顶点,接缝处无法展开纹理,会出现纹理"撕裂"或错位。
错误四:索引循环的方向反了
原文档指出:"each slice as (loops + 1) vertices so I think the loops in the code are backward"(每一片切片有loops + 1个顶点,因此原代码中索引循环里两层循环的角色是反的)。顶点布局是"按 slice 逐行排列",每个 slice 占据连续的loops + 1个顶点,索引生成时必须先按 slice 分组,再在 slice 内遍历 loop,顺序颠倒会导致四边形对角线方向错误、网格被扭曲甚至产生重叠三角形。
正确的实现方式:按切片与环计数循环
原文档给出的建议是"rather than loop based on the angles I'd loop based on the loops and slices"(与其基于角度累加循环,不如直接基于 loops 和 slices 计数循环)。仓库中的 完整示例 正是这种写法:
const o = { slices: 8, // 截面(管道圆周)方向的细分数 loops: 20, // 绕主轴方向的细分数 inner_rad: 0.5, // 管道截面半径 r outerRad: 2, // 圆环中心线半径 R makeVerts() { this.vertices = []; this.indices = []; this.normals = []; this.texCoords = []; for (let slice = 0; slice <= this.slices; ++slice) { const v = slice / this.slices; const slice_angle = v * 2 * Math.PI; const cos_slices = Math.cos(slice_angle); const sin_slices = Math.sin(slice_angle); const slice_rad = this.outerRad + this.inner_rad * cos_slices; for (let loop = 0; loop <= this.loops; ++loop) { // x=(R+r·cos(v))cos(w) // y=(R+r·cos(v))sin(w) // z=r·sin(v) const u = loop / this.loops; const loop_angle = u * 2 * Math.PI; const cos_loops = Math.cos(loop_angle); const sin_loops = Math.sin(loop_angle); const x = slice_rad * cos_loops; const y = slice_rad * sin_loops; const z = this.inner_rad * sin_slices; this.vertices.push(x, y, z); this.normals.push( cos_loops * sin_slices, sin_loops * sin_slices, cos_slices); this.texCoords.push(u); this.texCoords.push(v); } } const vertsPerSlice = this.loops + 1; for (let i = 0; i < this.slices; ++i) { let v1 = i * vertsPerSlice; let v2 = v1 + vertsPerSlice; for (let j = 0; j < this.loops; ++j) { this.indices.push(v1); this.indices.push(v1 + 1); this.indices.push(v2); this.indices.push(v2); this.indices.push(v1 + 1); this.indices.push(v2 + 1); v1 += 1; v2 += 1; } } }, }; o.makeVerts();这段代码的正确性体现在三处关键细节上:
- 循环边界带等号:
slice <= this.slices与loop <= this.loops,即每个方向都比细分数量多取一个顶点,用来闭合接缝; - 法线单独计算:法线为
(cos_loops·sin_slices, sin_loops·sin_slices, cos_slices),它指向管道截面圆心方向,与位置向量(x, y, z)不同; - 索引按行递推:
vertsPerSlice = loops + 1表示每片切片占据的顶点行宽,两个三角形拼成一个四边形网格单元。
索引拼接详解:每行loops + 1个顶点
顶点以"切片行"为单位顺序存放:第i片切片中的第j个顶点在数组中的下标为i * (loops + 1) + j。示例代码中的注释用一张网格图说明了这种布局:
// 0 1 2 3 4 5 // 6 7 8 9 10 11 // 12 13 14 15 16 17即每行是某一圈 loop 方向上的连续顶点,行与行之间是相邻的两片切片。对于第i行(下标v1 = i * vertsPerSlice)与第i + 1行(下标v2 = v1 + vertsPerSlice)之间的四边形单元,需要拆成两个三角形:
- 三角形一:
(v1, v1 + 1, v2); - 三角形二:
(v2, v1 + 1, v2 + 1)。
示例代码里循环体推进v1 += 1; v2 += 1;,保证每个四边形单元内部使用正确的环绕顺序。原文档提到"vertices must follow the right hand rule"(顶点必须遵循右手定则),上述三角形绕序使每个三角形的法线方向一致(朝外),这样配合gl.CULL_FACE开启背面剔除后不会出现面朝向翻转的问题。
示例中默认参数为slices = 8、loops = 20,因此:
- 顶点总数:
(8 + 1) × (20 + 1) = 189个; - 四边形单元数:
8 × 20 = 160个; - 三角形总数:
160 × 2 = 320个; - 索引总数:
320 × 3 = 960个。
法线计算原理:为什么法线不能等于位置
法线用于光照计算,它表示表面在该点的朝向。圆环面的法线必须指向管道截面圆心方向。对照参数方程,管道截面圆心在位置坐标中对应(R·cos(w), R·sin(w), 0),因此"从管道圆心指向表面点"的向量恰好是:
nx = cos(w) · sin(v) ny = sin(w) · sin(v) nz = cos(v)这正是示例代码中this.normals.push(cos_loops * sin_slices, sin_loops * sin_slices, cos_slices)的来源。可以验证它与位置向量的关系:当r趋于 0 时,位置向量约等于(R·cos(w), R·sin(w), 0),与法线显然不同——只有球体(R = 0)时才退化为"法线等于位置"的特例。
twgl 库中 createTorusVertices 的实现与示例完全一致:
const ringRadius = radius + sliceSin * thickness; const ny = Math.cos(sliceAngle); const y = ny * thickness; // ... const x = xSin * ringRadius; const z = zCos * ringRadius; const nx = xSin * sliceSin; const nz = zCos * sliceSin; positions.push(x, y, z); normals.push(nx, ny, nz);可见"法线 = (环向角的正弦 × 截面角的正弦, 截面角的余弦, 环向角的余弦 × 截面角的正弦)"是圆环面生成的标准做法。
纹理坐标的生成
示例代码在生成顶点与法线的同时,也输出了纹理坐标:
this.texCoords.push(u); // u = loop / loops this.texCoords.push(v); // v = slice / slices其中u沿环方向从0到1,v沿切片方向从0到1。关键在于每一圈末尾的顶点(loop === loops)与起点(loop === 0)位置重合,但 UV 坐标不同——起点u = 0,终点u = 1。这就是为什么必须生成loops + 1个顶点:若直接复用起点顶点,接缝处的纹理会出现拉伸或撕裂。twgl 库中对应的 UV 为texcoords.push(u, 1 - v)(方向取反,用于匹配其坐标系习惯),可见两处实现共享同一套"闭合接缝需独立顶点"的原则。
用示例验证结果:法线可视化渲染
完整示例 不只生成几何体,还用最简单的着色器把法线直接可视化为颜色,方便肉眼验证:
// 顶点着色器 attribute vec4 position; attribute vec3 normal; attribute vec2 texcoord; uniform mat4 u_matrix; varying vec3 v_normal; void main() { gl_Position = u_matrix * position; v_normal = normal; gl_PointSize = 3.0; } // 片元着色器 precision highp float; varying vec3 v_normal; void main() { gl_FragColor = vec4(v_normal * 0.5 + 0.5, 1); }因为法线各分量范围是[-1, 1],v_normal * 0.5 + 0.5把它映射到[0, 1]后即可作为 RGB 颜色输出。运行后你会看到:管道外侧(法线朝外)呈现均匀的渐变色彩,接缝处颜色连续、无跳变,说明法线方向正确;若把法线换成位置向量(原始代码的错误做法),色彩分布会与真实表面朝向不符。
示例的渲染循环通过 twgl 库组织渲染管线:
twgl.createProgramInfo(gl, [vs, fs])编译并链接着色器;twgl.createBufferInfoFromArrays(gl, {...})将position / normal / texcoord / indices数组打包成 BufferInfo;- 每帧用透视投影矩阵
m4.perspective(45°, 2, 0.1, 100)配合平移与旋转,调用twgl.drawBufferInfo绘制整个圆环。
仓库中的 矩阵数学教程 与 WebGL 基础入门 对投影矩阵、变换矩阵的作用有完整讲解,可作为理解渲染部分的前置阅读。
参数选型建议
createTorusVertices在 twgl 库中明确要求radialSubdivisions与bodySubdivisions至少为 3(见 3rdparty/twgl-full.module.js),否则直接抛错,防止生成退化网格:
if (radialSubdivisions < 3) { throw new Error('radialSubdivisions must be 3 or greater'); } if (bodySubdivisions < 3) { throw new Error('verticalSubdivisions must be 3 or greater'); }细分数量直接决定网格质量与性能开销:
| 参数 | 影响 | 建议 |
|---|---|---|
slices(截面细分) | 决定管道截面圆周的光滑度,数值过小会呈现多边形棱角 | 交互实时渲染用 8~24,静态或近景渲染可加大 |
loops(环向细分) | 决定圆环绕主轴方向的光滑度 | 常见取值 20~64,取值越大三角形越多 |
inner_rad/outerRad | 决定圆环的胖瘦比例 | 保持inner_rad < outerRad;两者越接近,管道越细 |
需要说明的是:细分越多,三角形数量越大,顶点着色器与片元着色器的负担越重,应根据实际可视距离与硬件能力权衡,并非越大越好。另外,示例与 twgl 的实现都默认生成"完整一圈"的圆环面;twgl 的createTorusVertices还额外支持startAngle/endAngle参数,可生成一段弧形的圆环(如半圆环),对需要"开口圆环"的场景可直接复用。
小结
生成 WebGL 圆环面的关键可以概括为四句话:
- 参数方程
x=(R+r·cos(v))cos(w)、y=(R+r·cos(v))sin(w)、z=r·sin(v)是顶点坐标的唯一来源; - 法线必须单独计算为
(cos(w)·sin(v), sin(w)·sin(v), cos(v)),绝不能复用位置向量; - 两个方向都要生成"细分数 + 1"个顶点以闭合接缝,起始与末尾顶点位置相同但 UV 不同;
- 索引按"每行
loops + 1个顶点"的网格布局,用两个三角形拼合每个四边形单元,并保持一致的绕序。
掌握了这四点,你就能写出与其他几何体生成(球体、圆柱、车床旋转体)风格一致的通用生成器;仓库中的 车床几何体教程 展示了更一般化的"任意截面绕轴旋转"思路,圆环面只是其中的一个特例。完整的可运行代码请直接查看 webgl-qna-how-to-create-a-torus-example-1.html,并可在 Q&A 索引页 中找到本问与其他 WebGL 实战问题的入口。
- 图形学
【免费下载链接】webgl-fundamentals
WebGL lessons that start with the basics
相关推荐
Three.js CylinderGeometry 圆柱几何体完整解析:从构造参数到底层顶点生成原理
Three.js CylinderGeometry 圆柱几何体完整解析:从构造参数到底层顶点生成原理 本文基于 three.js 官方文档中 CylinderG
前端3D渲染图形学WebGL 顶点索引(gl.drawElements)实战指南:从 drawArrays 迁移到索引绘制
WebGL 顶点索引(gl.drawElements)实战指南:从 drawArrays 迁移到索引绘制 导读 :本文基于 webgl fundamentals
图形学OpenCV视觉里程计开发入门:mono-vo项目结构与代码详解
OpenCV视觉里程计开发入门:mono vo项目结构与代码详解 想要快速掌握 视觉里程计 开发技术吗?mono vo项目为你提供了一个基于OpenCV的 单目
计算机视觉自动驾驶
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考