Web编程技术营地
研究、演示、创新

正多边形

撰写时间:2023-08-11

修订时间:2026-04-14

在前面几章中,我们仅能通过手工指定顶点位置的方式,构建一些简单的几何体。因此,前面的例子充斥了三角形、四边形。甚至都没法构建一个真正的正三角形,因为正三角形的各个顶点只有经过三角函数的计算才能精准定位。

在本章中,我们实现正多边的几何体。

正多边形包括正三角形、正方形、正五边形等等。这些正多边形都有一个规律,从原点到各个顶点的距离都是相等的。因此,正多边形可看作是一个圆被切分成特定部分后圆上各顶点的组合。

正多边形的本质

如果将所有正多边形都外接一个圆,则正多边形的所有顶点都位于圆周上。

根据三角函数,我们先编写一个名为genRegularPolygonPoints的生成正多边形顶点的函数:

function Point(x, y, z) { return {x, y, z}; // shortcut for {x:x, y:y, z:z} } function genRegularPolygonPoints(radius, sidesNum, isIncludeOrg = true) { let degreeStep = 360 / sidesNum; let points = []; if (isIncludeOrg) { points.push(Point(0.0, 0.0, 0.0)); } for (let degree = 0; degree < 360; degree += degreeStep) { let x = Math.cos(Geo.RadianFromDegree(degree)) * radius; let y = Math.sin(Geo.RadianFromDegree(degree)) * radius; points.push(Point(x, y, 0.0)); } return points; }

Point是返回一个点的对象的函数,该函数将坐标值打包进一个字面符对象的相应属性后返回该对象。可把其视为无需使用new语句的构造器。

genRegularPolygonPoints函数中,对给定半径由参数radius指定、多边形的边数由参数sidesNum指定的圆,根据三角函数公式计算出符合条件的所有圆点的X轴坐标值及Y轴坐标值,Z轴坐标值为0.0。然后将这些圆点打包进一个数组中并返回。除了各个圆点外,如果需要原点,则将原点置于数组的首位。

在计算所有圆点时,我们先将整个圆周划分为sidesNum等份,然后从0o开始计算至360o

该函数返回一个数组,其中每个元素都是类型为Point的对象。这种格式,在遍历数组元素及使用console.log()方法时都比较直观。但该格式不太适合于WebGL应用的使用。对于后者,应使用相应版本的genRegularPolygonVertices函数。

function genRegularPolygonVertices(radius, sidesNum, isIncludeOrg = true) { let points = genRegularPolygonPoints(radius, sidesNum, isIncludeOrg); let vertices = []; for (let point of points) { const {x, y, z} = point; vertices.push(x, y, z); } return vertices; }

该函数在内部调用了genRegularPolygonPoints函数,对所返回的数组,将每个Point对象的数据解包后,存储为一维数组的格式并返回。其格式诸如:

[ 0.0, 0.0, 0.0, // V0, origin point -0.5, 0.5, 0.0, // V1 -0.5, -0.5, 0.0, // V2 0.5, -0.5, 0.0, // V3 0.5, 0.5, 0,0 // V4 ];

注意,在调用该函数时,如果参数isIncludeOrg的值为true,则返回的结果中前3个元素代表原点的坐标值。

regular-polygons.html的核心代码:

function initFaceMeshes() { let radius = 0.6; let sidesNum = 3; let vertices = genRegularPolygonVertices(radius, sidesNum, true); let indices = Geo.GetIndicesFromVertices(vertices); let triangleIndices = Geo.GenTriangleIndicesInCircle(indices); let facesIndices = Geo.To2DArrays(triangleIndices, 3); faceMesh = new FaceMesh(vertices, facesIndices); faceMesh.renderMode = Mesh.RENDER_MODE.WIREFRAME; faceMesh.markVerticesPos({isShowCoords: false}); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); faceMesh.render(); }

调用genRegularPolygonVertices函数取得半径为0.6的三角形在圆周上的顶点,调用GetIndicesFromVertices来取得这些顶点的索引数组,再调用GenTriangleIndicesInCircle来将这些索引数组的索引值组合为适合于渲染为TRIANGLES的顶点索引数组。

之前我们调用过的GetCCWTriangleIndices方法只适用于代表多边形边界上的顶点索引数组,而如果需要以原点到各顶点的连线作为分界线来区分各个面,则需要另外的算法。GenTriangleIndicesInCircle方法适用于这种情况,其代码如下:

static GenTriangleIndicesInCircle(indices) { let orgIndex = indices.shift(); indices.push(indices[0]); let result = []; while(indices.length >= 2) { let index1 = indices.shift(); let index2 = indices[0]; result.push(orgIndex, index1, index2); } return result; }

每次取原点与两条邻边在圆周上的两个顶点构建一个三角形,这个算法与构建TRIANGLE_FANS的算法是一样的。但由于我们的Mesh默认采用构建TRIANGLES的方式,因此需将顶点索引数组依此需求进行转化。

求得这些单独三角形的顶点索引数组后,再调用To2DArrays方法转化为表示面的二维数组,最后调用FaceMesh构造器来生成其实例。

运行网页

我们发现,圆周上各个顶点从X轴的正轴开始为0o,按逆时针方向围绕Z轴旋转,逐渐增大角度旋转至360o。因此,上面代码渲染出来的三角形第一个顶点V1落在X轴的正轴,在视觉上看,此正三角形反倒好像向右旋转了90o。要调正三角形,顶点V1须逆时针旋转90o

将正多边形的边数依序改为412,观察图形的V1顶点及图形的朝向。

在默认的朝向的问题影响下,正方形渲染为菱形,边数为奇数的多边形歪了。

还有一个问题,将边数改为13,发现顶点V1V14几乎重叠在一起。既然为十三边形,不应出现顶点V14。十九边形也是这种情况。其原因,在于genRegularPolygonPoints函数中:

function genRegularPolygonPoints(radius, sidesNum, isIncludeOrg = true) { let degreeStep = 360 / sidesNum; ... for (let degree = 0; degree < 360; degree += degreeStep) { ... } return points; }

degreeStep不能被360整除时,就会产生浮点数,每次degree相加的过程中,都会有一点的细微误差。当边长为13时,最后一个degree的值为359.9999,比360小,因此就多创建了一个顶点。

当前的算法存在的上面两个问题,我们将在下个例子中予以修正。但此例可让我们清楚看出,使用三角函数在canvas中生成圆的各个顶点时,它总是从X轴的正轴开始并按逆时针旋转而成。记住这一点。

修正多边形的朝向与顶点数量

我们将上面的genRegularPolygonPoints函数及genRegularPolygonVertices函数都移至Geometries类而成为其方法。该类修改后的的相关代码如下:

const VERTICES_COMP_SIZE = 3; function Point(x, y, z) { return {x:x, y:y, z:z}; } ... class Geometries { ... static GenRegularPolygonPoints(radius, sidesNum, isIncludeOrg = true) { let degreeStep = 360 / sidesNum; let points = []; if (isIncludeOrg) { points.push(Point(0.0, 0.0, 0.0)); } let rotationDegree = 90; if (sidesNum === 4) { rotationDegree += 45; } let degree = rotationDegree; for (let pointNum = 1; pointNum <= sidesNum; pointNum++) { let radian = Geometries.RadianFromDegree(degree); let x = Math.cos(radian) * radius; let y = Math.sin(radian) * radius; points.push(Point(x, y, 0.0)); degree += degreeStep; } return points; } static GenRegularPolygonVertices(radius, sidesNum, isIncludeOrg = true) { let points = Geometries.GenRegularPolygonPoints(radius, sidesNum, isIncludeOrg); let vertices = []; for (let point of points) { const {x, y, z} = point; vertices.push(x, y, z); } return vertices; } ... }

所有正多边形从逆时针90o开始旋转,唯独正方形从135o开始旋转。并且,使用pointNum来精准控制生成的顶点数量。

客户端regular-polygons-1.html的主要代码修改如下:

function initFaceMeshes() { let radius = 0.6; let sidesNum = 4; let vertices = Geo.GenRegularPolygonVertices(radius, sidesNum, true); let indices = Geo.GetIndicesFromVertices(vertices); let triangleIndices = Geo.GenTriangleIndicesInCircle(indices); let facesIndices = Geo.To2DArrays(triangleIndices); faceMesh = new FaceMesh(vertices, facesIndices); faceMesh.renderMode = Mesh.RENDER_MODE.SOLID_WIREFRAME; faceMesh.markVerticesPos({isShowCoords: false}); }

运行应用。改变正多边形的边数,确保它们的朝向正确。

RegularPolygonMesh

上面虽然我们渲染出了正多边形,但却是通过FaceMesh类的实现的。没理由不实现一个RegularPolygonMesh类。该类代码如下:

export class RegularPolygonMesh extends FaceMesh { constructor(radius, sidesNum, isNeedOrg = false, facesColors) { if (isNeedOrg === true) { let vertices = Geo.GenRegularPolygonVertices(radius, sidesNum, isNeedOrg); let indices = Geo.GetIndicesFromVertices(vertices); let triangleIndices = Geo.GenTriangleIndicesInCircle(indices); let facesIndices = Geo.To2DArrays(triangleIndices); super(vertices, facesIndices, facesColors); } else { let vertices = Geo.GenRegularPolygonVertices(radius, sidesNum, isNeedOrg); super(vertices, null, facesColors); } } }

在构造函数中,生成vertices的方法一样,但根据参数isNeedOrg来决定是否需要原点,并依此生成不同的facesIndices。此外,接受从客户端传入进来的参数facesColors,并直接让其父类FaceMesh来处理。该类能比较智能地处理多种颜色的组合。

客户端regular-polygons-mesh.html代码:

function initFaceMeshes() { let radius = 0.6; let sidesNum = 8; faceMesh = new RegularPolygonMesh(radius, sidesNum, false, GLColors.SEAGREEN); faceMesh.renderMode = Mesh.RENDER_MODE.SOLID_WIREFRAME; faceMesh.markVerticesPos({isShowCoords: false}); }

运行应用

上面代码将生成一个正八边形,无原点(故只有一个面),且填充颜色为GLColors.SEAGREEN

如果省略最后两个参数,则生成一个随机填充颜色的单面正多边形。

CirclularMesh

虽然上面的RegularPolygonMesh用于生成正多边形,但我们看到,当边数足够多时,它就逼近为圆。但RegularPolygonMesh强调的是多边形的边数,用此类来构建一个圆,不够直观。因此,我们需要创建一个CircularMesh类来应对圆的情况。其代码如下:

export class CircularMesh extends RegularPolygonMesh { constructor(radius, pointsNum, isNeedOrg = false, facesColors) { super(radius, pointsNum, isNeedOrg, facesColors); } }

从代码上,CircularMesh继承RegularPolygonMesh,构造函数的参数改为pointsNum。在构造方法中,直接以这些构造方法的参数调用父类RegularPolygonMesh的构造方法即可。因此,CircularMesh实际上是RegularPolygonMesh的别名,但对于客户端使用时,比较友好。

客户端circluar-mesh.html代码:

function initCircularMeshes() { let radius = 0.6; let sidesNum = 32; circularMesh = new CircularMesh(radius, sidesNum); circularMesh.renderMode = Mesh.RENDER_MODE.WIREFRAME; }

运行应用

本章小结

功夫在诗外

在上一章及本章中,我们实现了较多的Mesh类。由于我们充分使用了类的继承的特性,在精心设计好基类后,各个子类就非常容易实现,甚至有较多的子类仅提供了构造方法就足以完成众多不同的需求,可谓代码简练,但功能强大。

最主要的是,我们在学习过程中,逻辑思维的角度与层次发生了重大转变。

要渲染一个圆形,如果没有我们之前所实现的类的支撑,我们就会思考:它有哪些顶点、如何传输至着色器?总共涉及多少个VAO, VBO, IBO?如何读写存储在WebGLBuffer之中的数据?这些底层的细节会严重妨碍我们从更高层次上思考如何解决业务问题。并且,每次要渲染一个简单的图形,都不得不编写越来越长的代码,我们的信心很快就会崩溃。

而有了各类的支撑后,要渲染一个圆形,我们仅需思考:该圆的半径是多少?共需要多少个顶点?以什么颜色填充?不经意间,我们逻辑思维发生了重大转变。

当我们解决了旧的业务问题,遇到新的业务需求时,我们再去看WebGLAPIs中能为我们提供什么样的功能,学习它们,并应用它们来解决新的业务问题。这就是以解决业务问题为驱动的学习方式(bussiness-driven method)。

所以,WebGLAPIs是用来应用的,不是用来学习与强记的。当我们不得不去翻阅一条又一条的API时,我们已经彻底地迷失在丛林中。在这种情况下,我们如何能坚持学下去?

将我们开始学习如何渲染一个纯色三角形时那种苦苦挣扎的痛苦状态,与现在学习如何渲染一个正多边形的Mesh的体验相比,我们就会发现,WebGL编程越来越简单、越来越有趣了。

因此,功夫在诗外。我们所创建的各个类,仅在于能帮助我们从更抽象、更高层面上、更好地学习、理解并应用WebGL的相关技术。

类的职责与分工

到目前为止,我们已经根据实际需要,创建了各种实用的类。

WebGLUtils负责提供最底层的支持,WebGLBufferUtils专注于与VBO打交道,CanvasUitls负责在画布上显示顶点位置信息,GLColors负责提供丰富多彩的颜色,Geometries负责对几何体的顶点进行底层的操作与转换,各个Mesh类则从最高层提供了最接近于业务逻辑的接口调用。

而在类与类之间的交互上,我们有意识地引入并应用各种设计模式,各类之间的耦合度大为降低,各个类均自由、充分、最大潜力地发挥了他们各自应有的作用,系统扩展性更强、代码维护与升级完善更加方便。

在以后的学习过程中,我们将始终遵循这些良好的编程习惯,设计出更多、更强、更实用的类。

参考资源

  1. WebGL 2.0 Specification