资讯动态

LS3/NS3球体生成算法详解:经纬球与细分球的Java实现

发布时间:2026/10/17 7:55:32 来源:尧图企业网站定制
LS3/NS3球体生成算法这个名字第一次看到的时候十有八九会愣一下——它既不像图形学公开课里的标准术语也不像某个成熟开源库的接口命名。如果你也被这两个代号卡住过先别急着去搜索引擎里逐字母匹配。在我参与过的项目里这类代号大概率只是内部对两类球体网格生成方式的约定称呼LS3偏经纬分层球按经度切片、纬度堆叠来布点NS3偏归一化细分球从初始多面体出发递归细分后再把顶点单位化到球面。这篇文章就按这个约定展开用纯Java把两条路线都实现出来覆盖顶点生成、索引绕序、法线处理、接缝与极点问题、性能取舍最后给出实测对比和避坑记录。如果你正在写3D可视化、低多边形球体Demo或者只是想在不用重型引擎的情况下自己生成一份可用的球体网格数据这篇博文可以直接照着敲。看完你能得到两份独立的网格构建器一份拉线球LatLong Sphere一份细分球Icosahedron Subdivision Sphere它们输出的顶点、法线、纹理坐标和三角形索引可以直接接进常见的渲染管线或导出成文本模型文件。1. LS3/NS3不是一个标准算法先厘清项目里的代号含义1.1 我理解的LS3经纬分层球经纬球是大多数人接触球体网格时的第一选择思路非常直白把球体想象成地球仪经度方向切若干刀纬度方向切若干刀切出来的交点就是顶点每个四边形网格拆成两个三角形就是一份球体网格。所谓LS3我倾向于拆成LSLayer Segment加33D也就是三维分层分段的球体网格。你可以把它理解成一组按经纬方向组织的顶点矩阵。这组矩阵天然带着UV坐标经度对应U纬度对应V。所以如果你后续要贴一张世界地图纹理经纬球是最合适的起点因为它的参数化和纹理映射是严格绑定的。但经纬球的代价也很明显极点处所有经线汇成一个点导致极点附近的三角形极度退化、面积趋近于零而赤道附近的三角形则相对舒展。换句话说它的网格密度在各处差别很大。之后你会看到这个特点在渲染时带来的具体问题。1.2 我理解的NS3归一化细分球NS3我倾向于理解为Normalized Subdivision Sphere——归一化细分球。它的思路和经纬球完全不同先取一个顶点分布相对均匀的初始多面体比如正二十面体然后对每个三角形做细分每一条边插入中点一个三角形变成四个再把所有新顶点沿径向推到球面上也就是归一化到固定半径。这样做出来的球体所有三角形都接近等边三角形大小差异也比较小没有极点也不会出现经度接缝。它的顶点分布质量远高于经纬球代价是需要处理更复杂的索引关系和一点点算法结构。它不是直接由参数方程逐点算出来的而是从多面体出发逐步逼近球面。这就是它和经纬球最本质的区别一个是从参数方程采样一个是拓扑细分加投影归一化。1.3 两种路线为什么需要并存既然细分球质量更好是不是经纬球就该被淘汰并不是。经纬球的UV映射太友好了做球面纹理、做简单的行星地形、做教学演示都非常顺手细分球则适合那些对三角形质量敏感的场景比如光照计算、物理碰撞网格、低多边形风格建模。后面第5章我会给出一组实际对比数据这里先记住一个结论不存在绝对更好的球体生成算法只有更适合当前场景的网格结构。LS3/NS3这两条路线放在一起实际是把球体生成这个问题的两个典型答案讲透。2. 经纬球实现从球坐标公式到第一份网格数据2.1 球坐标参数化与顶点生成经纬球的核心数学是球坐标参数化。设球体半径为radius经度角theta从0到2π纬度角phi从-π/2到π/2那么球面上任意一点的坐标是x radius * cos(phi) * cos(theta) y radius * sin(phi) z radius * cos(phi) * sin(theta)我习惯让Y轴朝上这样phi直接对应纬度从北极到南极。如果你所在的坐标系是Z轴朝上只需要把y和z的赋值互换即可核心逻辑不变。下面是完整的Java构建器。我用一个简单的Mesh类来承载顶点、法线、纹理坐标和索引数据不依赖任何第三方库只用到Java标准集合。import java.util.ArrayList; import java.util.List; public class Mesh { public final ListFloat vertices new ArrayList(); public final ListFloat normals new ArrayList(); public final ListFloat texCoords new ArrayList(); public final ListInteger indices new ArrayList(); }public class LatLongSphereBuilder { public Mesh build(float radius, int slices, int stacks) { Mesh mesh new Mesh(); float piDiv (float) Math.PI; for (int stack 0; stack stacks; stack) { // phi从90度到-90度对应stack从0到stacks double phi Math.PI * (0.5 - (double) stack / stacks); float y (float) (radius * Math.sin(phi)); float ringRadius (float) (radius * Math.cos(phi)); for (int slice 0; slice slices; slice) { double theta 2 * Math.PI * slice / slices; float x (float) (ringRadius * Math.cos(theta)); float z (float) (ringRadius * Math.sin(theta)); mesh.vertices.add(x); mesh.vertices.add(y); mesh.vertices.add(z); // 对单位球而言位置归一化后就是法线 float len (float) Math.sqrt(x * x y * y z * z); mesh.normals.add(x / len); mesh.normals.add(y / len); mesh.normals.add(z / len); // UVU沿经度方向V沿纬度方向 mesh.texCoords.add((float) slice / slices); mesh.texCoords.add((float) stack / stacks); } } // 索引生成每个四边形拆成两个三角形 for (int stack 0; stack stacks; stack) { for (int slice 0; slice slices; slice) { int cur stack * (slices 1) slice; int next cur 1; int curBelow cur slices 1; int nextBelow curBelow 1; mesh.indices.add(cur); mesh.indices.add(next); mesh.indices.add(curBelow); mesh.indices.add(next); mesh.indices.add(nextBelow); mesh.indices.add(curBelow); } } return mesh; } }调用的时候比如想生成半径1.0、经度36份、纬度18份的球体LatLongSphereBuilder builder new LatLongSphereBuilder(); Mesh sphere builder.build(1.0f, 36, 18);这里slices和stacks的选择有讲究。slices是经度切分数stacks是纬度切分数数值越大网格越细。我实际做项目时简单的预览用16x8就足够正式场景一般用36x18或48x24再往上对经纬球来说意义不大反而会把极点附近的退化三角形堆得更多。2.2 索引绕序最容易被忽视的方向问题上面这段代码里三角形索引的绕序并不是随便写的。常见的图形接口在开启背面剔除后默认认为从外侧看逆时针排列的三角形是正面。如果绕序写反你会看到一个内面朝外的球或者干脆只能看到球的内壁。我为什么写成(cur, next, curBelow)而不是(cur, curBelow, next)原因在于顶点的生成顺序cur到next是沿经度方向递增cur到curBelow是沿纬度方向递减。把这两个方向做叉积得到的法向量朝外正好吻合从球外看向三角形时顶点逆时针排列的约定。如果你在自己的渲染环境里发现球看起来是反的最直接的修法不是重新生成顶点而是把索引数组里每两个三角形的索引顺序交换比如把cur,next,curBelow换成cur,curBelow,next。我几乎每次写新的渲染环境都会遇到一次这种问题不必纠结数学交换一下就行。2.3 极点和经度接缝经纬球天生的两个坑第一个坑是极点退化。在stack0北极的位置代码会执行slices1次循环每次计算出来的x和z在数学上都应该是0但因为在浮点运算里cos(π/2)约等于6e-17实际生成的可能是极小的非零值。也就是说北极一圈产生了slices1个位置几乎重合的顶点这些顶点拓扑上本应合并成一个。这在大多数场景下肉眼看不出来但如果你做的是高精度网格处理或者要把网格导出做进一步几何计算就会遇到重复顶点问题。工程上更稳妥的做法是把两极单独处理在北极只压入一个顶点在南极只压入一个顶点中间每一层再按slices1个顶点生成。这样索引生成要稍微改一下但网格拓扑是干净的。第二个坑是经度接缝。slice从0到slices其中slice0和sliceslices对应的是同一个经线位置我们却生成了两个顶点。这样顶点总数是(slices1)(stacks1)而不是slices(stacks1)。多出来的那一列顶点纯粹是为了让UV的U值从0连续过渡到1。如果你不需要纹理可以省掉这一列让slice0和sliceslices共用同一个顶点减少一些重复数据如果需要纹理就必须留着一列否则贴图在接缝处会撕裂。3. 细分球实现二十面体递归细分的高质量网格3.1 为什么初始多面体选正二十面体细分球的第一步是选初始多面体。正多面体里只有四种可以用三角面构造正四面体、正八面体、正二十面体以及正六面体通过拆分得到的三角化变体。其中正二十面体有20个三角面、12个顶点、30条边是所有正多面体里面数最多、最接近球形的。它接近球形的程度有多好你可以这样理解把正十二面体和正二十面体都放到单位球里比较正二十面体的顶点到球面的最大距离差更小整体包裹性更好。选择它作为起点意味着后续细分时每个三角形都相对均匀细分的代价也最小。另一个现实原因正二十面体的顶点坐标有极其简洁的构造方式全部由0、±1、±黄金比例组成不需要复杂的矩阵运算代码实现非常干净。3.2 中点归一细分一个三角形变成四个细分过程可以总结成三步。第一步把每条边的中点插入到顶点列表中。第二步把原三角形替换成四个小三角形三个角各分到一个中间一个反向连接三条中点。第三步把所有新顶点归一化到球面半径上。归一化这一步是整个算法的灵魂。从多面体到球面的关键不是简单的递归切分——如果只切分不归一化你只会得到一个越来越细的二十面体而不是球。归一化会把每个顶点沿径向缩放使得所有顶点都落在半径为radius的球面上。因为原始顶点已经在球面上所以只需要处理每次新增的中点和后续迭代中的次级顶点。一个值得注意的细节归一化应该在每次细分后立即对新增中点做而不是等所有细分结束后再统一归一化。原因是后续细分依赖当前顶点已经位于球面上如果让不在球面上的点去参与下一轮中点计算会产生整体形状偏离球面的累积误差。3.3 共享中点缓存没有它就会裂开细分算法里最容易翻车的地方不是数学公式而是索引管理。每个新三角形都需要知道它三条边的中点但一条边通常被两个相邻三角形共享。如果你在每个三角形里各自独立地计算这条边的中点就会在相邻三角形之间产生两个位置几乎一样但索引不同的顶点最终在渲染时出现一条条细小的裂纹。解决办法是维护一个边到中点索引的缓存。每次计算中点前把边的两个端点索引按大小排序拼成一个long类型的键查缓存如果已有中点直接复用如果没有才生成新顶点并放入缓存。下面是完整实现。import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class SubdivisionSphereBuilder { // 黄金比例约1.618 private static final float PHI 1.61803399f; // 正二十面体12个顶点每三个一组x, y, z private static final float[] ICOSAHEDRON_VERTICES { -1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, 0, 0, -1, PHI, 0, 1, PHI, 0, -1, -PHI, 0, 1, -PHI, PHI, 0, -1, PHI, 0, 1, -PHI, 0, -1, -PHI, 0, 1 }; // 正二十面体20个三角面 private static final int[][] ICOSAHEDRON_FACES { {0, 11, 5}, {0, 5, 1}, {0, 1, 7}, {0, 7, 10}, {0, 10, 11}, {1, 5, 9}, {5, 11, 4}, {11, 10, 2}, {10, 7, 6}, {7, 1, 8}, {3, 9, 4}, {3, 4, 2}, {3, 2, 6}, {3, 6, 8}, {3, 8, 9}, {4, 9, 5}, {2, 4, 11}, {6, 2, 10}, {8, 6, 7}, {9, 8, 1} }; public Mesh build(float radius, int subdivisionLevel) { Listfloat[] vertices new ArrayList(); for (int i 0; i ICOSAHEDRON_VERTICES.length; i 3) { float[] v new float[] { ICOSAHEDRON_VERTICES[i], ICOSAHEDRON_VERTICES[i 1], ICOSAHEDRON_VERTICES[i 2] }; vertices.add(normalize(v, radius)); } Listint[] faces new ArrayList(); for (int[] face : ICOSAHEDRON_FACES) { faces.add(face.clone()); } MapLong, Integer midpointCache new HashMap(); for (int level 0; level subdivisionLevel; level) { Listint[] refinedFaces new ArrayList(); for (int[] tri : faces) { int a tri[0], b tri[1], c tri[2]; int ab getMidpointIndex(a, b, vertices, midpointCache, radius); int bc getMidpointIndex(b, c, vertices, midpointCache, radius); int ca getMidpointIndex(c, a, vertices, midpointCache, radius); refinedFaces.add(new int[] {a, ab, ca}); refinedFaces.add(new int[] {ab, b, bc}); refinedFaces.add(new int[] {ca, bc, c}); refinedFaces.add(new int[] {ab, bc, ca}); } faces refinedFaces; } Mesh mesh new Mesh(); for (float[] v : vertices) { mesh.vertices.add(v[0]); mesh.vertices.add(v[1]); mesh.vertices.add(v[2]); float len (float) Math.sqrt(v[0] * v[0] v[1] * v[1] v[2] * v[2]); mesh.normals.add(v[0] / len); mesh.normals.add(v[1] / len); mesh.normals.add(v[2] / len); } for (int[] tri : faces) { mesh.indices.add(tri[0]); mesh.indices.add(tri[1]); mesh.indices.add(tri[2]); } return mesh; } private static float[] normalize(float[] v, float radius) { float len (float) Math.sqrt(v[0] * v[0] v[1] * v[1] v[2] * v[2]); return new float[] { v[0] / len * radius, v[1] / len * radius, v[2] / len * radius }; } private static int getMidpointIndex( int i1, int i2, Listfloat[] vertices, MapLong, Integer cache, float radius) { int lo Math.min(i1, i2); int hi Math.max(i1, i2); long key ((long) lo 32) | hi; Integer cached cache.get(key); if (cached ! null) { return cached; } float[] a vertices.get(lo); float[] b vertices.get(hi); float[] mid normalize(new float[] { (a[0] b[0]) / 2f, (a[1] b[1]) / 2f, (a[2] b[2]) / 2f }, radius); int index vertices.size(); vertices.add(mid); cache.put(key, index); return index; } }调用方式SubdivisionSphereBuilder builder new SubdivisionSphereBuilder(); Mesh sphere builder.build(1.0f, 3);细分等级从0开始。等级0就是最原始的二十面体等级1有42个顶点、80个三角面等级2有162个顶点、320个三角面等级3有642个顶点、1280个三角面等级4就到了2562个顶点、5120个三角面。实际项目里3级细分已经足够平滑4级多数时候是在浪费内存。4. 法线、索引复用和网格质量检查4.1 球面法线直接归一化位置还是叉乘球体的法线有一个非常优雅的性质如果球心在原点那么球面上任意一点的方向就是该点的法线方向。换句话说把顶点坐标直接归一化就得到法线。这也是经纬球代码里我直接用len去除x、y、z的原因不用额外做叉积。但有几个例外需要留意。第一如果球体半径是1那么法线就是顶点坐标本身连除法都可以省掉。第二如果你的球体不是标准球而是经过缩放、平移的椭球体那就不能直接归一化位置必须用相邻边的叉积来计算面法线再按顶点平均得到平滑法线。第三如果你的顶点经过了噪声扰动比如做球形地形位置已经不在球面上法线同样必须重新计算。叉积法线是更通用的做法但不一定更好。对纯球体来说用位置归一化既快又准对变形体来说叉积才是可靠的。我通常在代码里保留两种方案baseNormal()和computeFaceNormal()前者供纯球体使用后者在网格被后续修改时兜底。4.2 索引复用为什么网格必须有共享顶点经纬球和细分球都强调边长中点要复用或接缝处要不要多一列顶点核心都在于网格顶点共享。如果每个三角形都用自己独立的一份坐标顶点数量会膨胀到原来的三倍浪费显存更严重的是共享顶点在不同三角形间不能保证数值完全一致浮点误差会产生裂缝。索引缓冲区的意义正在于此让同一个顶点被多个三角形引用而不是复制多份坐标。对细分球来说中点缓存效果从等级1就开始体现。正二十面体有30条边如果不缓存每个三角形单独生成中点1级细分会产生60个中点而不是30个三角形之间还可能出现肉眼可见的接缝。缓存后中点数量严格等于当前边数网格拓扑完全闭合。经纬球也一样。除了接缝处那列重复顶点是有意为之其他位置的顶点都只应出现一次。代码里slices1和stacks1的选择本质上就是在处理最后一个顶点要不要和第一个顶点合并的取舍。4.3 导出网格文本格式做线框检查和面积统计写完构建器之后最有效的验证方式不是直接用眼睛看渲染效果而是先把网格导出成文本格式用工具检查。我经常把顶点、法线和索引输出成简单的v/f格式每行以v开头表示顶点坐标以f开头表示三角形索引。这种纯文本格式可以直接被很多网格查看工具打开。public void export(Mesh mesh, String filePath) throws IOException { try (var writer new java.io.PrintWriter(filePath, UTF-8)) { for (int i 0; i mesh.vertices.size(); i 3) { writer.printf(v %f %f %f%n, mesh.vertices.get(i), mesh.vertices.get(i 1), mesh.vertices.get(i 2)); } for (int i 0; i mesh.indices.size(); i 3) { writer.printf(f %d %d %d%n, mesh.indices.get(i) 1, mesh.indices.get(i 1) 1, mesh.indices.get(i 2) 1); } } }除了视觉检查我还会在代码里统计每个三角形的面积看看最大值和最小值的比值。经纬球如果slices和stacks取36x18极点附近三角形面积和赤道附近能差几十倍细分球3级以后这个比值通常能压到3倍以内。这个指标比看起来圆不圆更能反映网格质量。5. 实测对比经纬球与细分球在不同场景下的取舍5.1 顶点分布均匀度数据说话我用一组接近的参数做对比经纬球36x18细分球3级。两者的三角面数几乎一样但数据结构差异很明显。指标经纬球36x18细分球3级顶点数703642三角面数12961280极点两个退化极点无极点概念三角形形状极区瘦长赤道舒展整体接近等边UV坐标天然匹配平面贴图需要额外映射最大/最小三角形面积比几十比一约2比1到3比1这个数据说明一个关键问题经纬球的面数只是看起来够用在北极和南极附近大量三角形挤在一起光照和贴图都容易出问题细分球则把三角形均匀地分配到了球面各处。5.2 光照与纹理表现从球面上看起来的区别如果做了一个带镜面高光的材质经纬球在极点附近会出现明显的高光断裂或闪烁因为退化三角形让法线方向在极小区间内急剧变化。细分球的法线过渡更平滑高光形状更稳定。纹理方面又是另一回事。经纬球的UV天然和经纬度对应贴一张世界地图立刻就能看细分球没有这种天然参数化如果硬要贴图需要把顶点坐标转成笛卡尔平面坐标再映射通常会产生一定程度的拉伸或扭曲。所以做行星纹理、地球仪这类需求我优先选经纬球做高光材质、低多边形装饰球、碰撞检测网格我优先选细分球。5.3 实现复杂度与运行开销如何按场景选择从实现复杂度看经纬球大概20行核心代码思路直接几乎没有失败风险细分球则需要维护面列表、缓存中点、处理递归迭代明显复杂一截。对一次性工具或教学代码来说经纬球是性价比之王。从运行开销看相同面数下两者的构建耗时都在毫秒级真正影响性能的是细分等级。细分球每增加一级面数翻四倍顶点数大约翻四倍但略有减少。等级4以上就没必要了移动端设备尤其吃亏。经纬球不会出现这种爆炸增长你把slices和stacks固定住数据量就是稳定的。如果你问我最终选哪条我的习惯是预览和纹理相关需求用经纬球正式渲染、光照敏感需求用细分球而且初始等级从2到3起步绝大多数情况够用。6. 实操避坑记录与扩展建议6.1 绕序反了导致只能看到内表面这是我在给不同渲染环境接网格时踩得最多的一坑。球体生成代码本身没问题但一旦环境换了坐标系、换了默认正面方向原本的逆时针排列可能就变成顺时针了。症状非常一致球体一渲染内侧看起来是正常的外侧反而是透明的或者被剔除掉了。排查方法很简单把线框模式和背面剔除一起打开如果看到三角形法线全都指向球心那基本就是绕序问题。解决方法是交换索引数组里每三个元素的前两个顺序。不要试图去改动顶点生成公式那个不是根源。6.2 浮点误差与顶点不共享造成的细缝细缝问题在经纬球极点处和细分球中点处都可能出现。经纬球的极点我前面说过cos(π/2)在浮点运算下不是精确的0每个经度位置算出来的极点坐标有微小差异。虽然肉眼很难察觉但如果你把网格导出去做几何运算就会发现极点有slices1个几乎相同的顶点。如果是严谨的工程场景建议在极点处只生成一个顶点并单独走索引分支。细分球的中点如果不缓存裂缝是实打实的视觉问题。每个三角形独立生成的中点在浮点层面会有微小偏差两个相邻三角形在拼接处会出现一条发丝般的缝隙背景颜色会从缝里透出来。这个问题在写第3章代码时已经通过midpointCache解决但如果你是自己从零写一定要记得缓存。另外注意MapLong, Integer的key构造我用的是((long) lo 32) | hi。这个技巧成立的前提是hi不会超过32位整数范围。对球体细分来说4级细分也才几千个顶点完全安全但如果把细分等级推到10级以上顶点数量上亿这个方案就要换了。6.3 高细分等级带来的几何级数增长细分等级不是越高越好。很多人第一次接触细分球会忍不住试到等级5甚至等级6结果程序瞬间吃进去几百万个三角形帧率暴跌然后得出结论细分球效率不行。实际上等级4的5120个三角形在实时渲染里已经非常顺滑肉眼根本分辨不出等级3和等级4的轮廓差别只有在极近距离观察轮廓边缘时才有一点点区别。移动端建议控制在等级3以内桌面端等级4封顶。再往上纯粹是拿内存换那一丝根本看不出来的圆滑度。6.4 后续可以玩的几个扩展方向这两套构建器做完之后你可以很自然地往下面几个方向扩展。第一是球形噪声地形。把细分球的顶点位置在法线方向上做一层叠加噪声扰动就能得到一个低多边形星球。经纬球也可以做但因为极点附近三角形退化噪声效果会在两极出现明显的条纹。第二是LOD细节层次。根据球体到相机的距离切换不同的细分等级。这个思路在经纬球里不太方便因为每套slices和stacks都是独立的网格切换时会有比较明显的跳变细分球则可以通过等级切换做平滑过渡因为低等级网格的顶点集合是高等级网格的子集。第三是纹理映射。经纬球直接用UV贴图即可细分球如果非要贴纹理可以考虑立方体映射也就是把球面坐标投影到立方体六个面再采样纹理虽然会有接缝但比强行展开会均匀很多。我在实际项目里的体会是球体生成算法看着是几何基础题真正磨时间的全在索引管理、绕序校验和共享顶点这些细节上。你把这些边界想清楚代码怎么写都不会出大问题。最后建议你拿到这两份构建器之后先导出一份文本网格用查看工具从线框、法线、面积统计三个角度检查一遍再决定往哪个方向扩展这会帮你省下大量排查渲染问题的时间。

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

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

免费获取报价 →
↑