目录

OpenGL图元绘制和图元重启

发布于 2023/04/15 更新于 2023/04/15

作者 趣宽科技 码云上的源文件

OpenGL的主要作用就是将图形渲染到帧缓存中。为了实现这一要求,需要将复杂的物体分解成图元的形式(包括点、线、以及三角形),当它们分布密度足够高时,就可以表达为2D或3D物体形态。

OpenGL支持很多种不同的图元类型。不过最后它们都可以归结为三种类型中的一种,即点、线、或三角形。线和三角形图元类型可以再组合为条带、循环体(线),或者扇面(三角形)。点、线和三角形也是大部分图形硬件设备所支持的基础图元类型。

H1

点可以通过单一的顶点来表示。一个点也就是一个四维的齐次坐标。因此、点实际上不存在面积,在OpenGL中它是通过显示屏幕上的一个四边形区域来模拟的。当渲染图元的时候,OpenGL会通过一系列光栅化规则来判断点所覆盖的像素位置。四边形区域的边长等于点的大小,可以通过glPointSize或在片元着色器中向内置变量gl_PointSize写入值来进行改变,不过只有开启了GL_PROGRAM_POINT_SIZE状态之后,我们才能在着色器中写入gl_PointSize,否则这个值被忽略。

为了说明基本图元的绘制,现在设定了17顶点,它们的序号和在屏幕中的相对位置如下图所示:

/assets/images/article/opengl/89cb2d1a-fa21-451d-8c20-6dc60e32f6b8

17个顶点坐标数据是:

    
// 17个顶点
static const GLfloat vertex_positions[] =
{
    -0.7f,  0.2f, 0.0f, 1.0f,
    -0.6f,  -0.2f, 0.0f, 1.0f,
    -0.5f,  0.2f, 0.0f, 1.0f,
    -0.4f,  -0.2f, 0.0f, 1.0f,
    -0.3f,  0.2f, 0.0f, 1.0f,
    -0.2f,  -0.2f, 0.0f, 1.0f,
    -0.1f,  0.2f, 0.0f, 1.0f,
    0.0f,  -0.2f, 0.0f, 1.0f,
    0.1f,  0.2f, 0.0f, 1.0f,
    0.2f,  -0.2f, 0.0f, 1.0f,
    0.3f,  0.2f, 0.0f, 1.0f,
    0.4f,  -0.2f, 0.0f, 1.0f,
    0.5f,  0.2f, 0.0f, 1.0f,
    0.6f,  -0.2f, 0.0f, 1.0f,
    0.7f,  0.2f, 0.0f, 1.0f,
    0.8f,  -0.2f, 0.0f, 1.0f,
    0.9f,  0.2f, 0.0f, 1.0f,
};
                    

在OpenGL中绘制点使用:


setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_BLUE));
glPointSize(10.0f);
glDrawArrays(GL_POINTS, 0, 17);    
                    

上述代码将在屏幕中绘制17个点,不过点呈现的不是圆形而是四边形区域,也没有中间的数字标注。

H1

线

OpenGL当中的线表示一条线段,而不是数学意义上的无限延伸的方向向量。独立的线可以通过一对顶点来表达,每个顶点表示线的一个端点。

   
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glDrawArrays(GL_LINES, 0, 17); 
                    

/assets/images/article/opengl/c8aae68c-df6b-4ea4-82ff-9b0814b9b10e

因每条线段有两个顶点,所以有8条线段,剩下一个顶点无法渲染成线段。

H1

条带线(Line stip)

多个顶点进行链接来表示一系列的线段,条带线首尾没有闭合。

    
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glDrawArrays(GL_LINE_STRIP, 0, 17); 
                    

/assets/images/article/opengl/785d0d51-9115-4757-8189-2f4b49634a0d

H1

循环线(Line loop)

与条带线不同的是,循环线首尾是闭合的。

    
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glDrawArrays(GL_LINE_LOOP, 0, 17); 
                    

/assets/images/article/opengl/ca099465-cef4-4ae0-87bf-ee71f4cb55dc

可以看到开始点和结束点之间有一条连线。首尾进行了闭合。

H1

三角形

三角形是三个顶点的结合组成的。当我们分别渲染多个三角形的时候,每个三角形都与其他三角形完全独立。

    
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_BLUE));
glDrawArrays(GL_TRIANGLES, 0, 17);   
                    

/assets/images/article/opengl/07d27304-c8eb-4436-90b6-ef5d47a568e3

因为共有17个顶点,因此共绘制了5个三角形,剩下两个顶点无法渲染成一个三角形。

H1

三角形条带(Triangle stip)

与独立的三角形不同的是, 前三个顶点将会构成第一个三角形,后继的顶点将与之前三角形的后两个顶点一起构成新的三角形。

    
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 17);
                    

/assets/images/article/opengl/96d2b69a-8509-43a1-9e08-5498e3a7f793

H1

三角形扇面(Triangle Fan)

当渲染三角形扇面的时候,第一个顶点会作为共享点存在,它将作为每一个后继三角形的组成部分。而之后的每两个顶点都会与这个共享点一起组成新的三角形。以上述的顶点数据为例,为了更形象的说明问题,对其中的顶点坐标做一些调整。将一个顶点移到原点下方,将其他顶点的y值全部变为正数。

    
static const GLfloat vertex_triangle_fan_positions[] =
    {
        0.0f,  -0.7f, 0.0f, 1.0f,
        -0.6f,  0.2f, 0.0f, 1.0f,
        -0.5f,  0.2f, 0.0f, 1.0f,
        -0.4f,  0.2f, 0.0f, 1.0f,
        -0.3f,  0.2f, 0.0f, 1.0f,
        -0.2f,  0.2f, 0.0f, 1.0f,
        -0.1f,  0.2f, 0.0f, 1.0f,
        0.0f,  0.2f, 0.0f, 1.0f,
        0.1f,  0.2f, 0.0f, 1.0f,
        0.2f,  0.2f, 0.0f, 1.0f,
        0.3f,  0.2f, 0.0f, 1.0f,
        0.4f,  0.2f, 0.0f, 1.0f,
        0.5f,  0.2f, 0.0f, 1.0f,
        0.6f,  0.2f, 0.0f, 1.0f,
        0.7f,  0.2f, 0.0f, 1.0f,
        0.8f,  0.2f, 0.0f, 1.0f,
        0.9f,  0.2f, 0.0f, 1.0f,
    };
    
    
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
glDrawArrays(GL_TRIANGLE_FAN, 0, 17);        
                    

绘制效果如下:

/assets/images/article/opengl/f0d292eb-0463-45aa-a658-623833f861b9

H1

四边形(GL_QUADS)

四边形是四个顶点的结合组成的。当我们分别渲染多个四边形的时候,每个四边形都与其他四边形完全独立。

    
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glDrawArrays(GL_QUADS, 0, 17);
                    

/assets/images/article/opengl/effc015b-9da1-488d-88a1-a8433178c78e

H1

四边形条带(GL_QUAD_STRIP)

与独立的四边形不同的是, 前四个顶点将会构成第一个四边形,后继的顶点将与之前四边形的后两个顶点一起构成新的四边形。

    
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);    
glDrawArrays(GL_QUAD_STRIP, 0, 17);
                    

/assets/images/article/opengl/0145b8be-694e-48f2-ba73-4cc204862ae6

H1

多边形(POLYGON)

多边形就是把顶点按照顺序链接起来,且首尾集合的图形。我们使用三角形扇面的顶点作为例子。

   
// 多边形
glBindVertexArray(m_vao[1]);
glEnableVertexAttribArray(0);

setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_GREEN));
glPointSize(10.0f);
glDrawArrays(GL_POINTS, 0, 17);

setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
glDrawArrays(GL_POLYGON, 0, 17);
                    

/assets/images/article/opengl/910d9100-c370-4e00-a7dc-bef6696d5339

H1

多边形渲染成点集、轮廓或者实体

一个多边形有两个面:正面和背面,当不同的面朝向观察者时,它们的渲染结果可能是不一样的。因此在观察一个物体的剖面时,可以很明显地区分出它的内部和外部表面。默认情况下,正面和背面的绘制方法是一致的,都是填充模式(GL_FILL)。如果要改变这一属性,或者进行使用轮廓线或者顶点来绘制的话,可以调用glPolygonMode命令。


glPolygonMode(GLenum face, GLenum mode)
                    

比如下面的代码演示了正面使用填充模式,背面使用轮廓线模式。

    
glPolygonMode(GL_FRONT, GL_FILL);
glPolygonMode(GL_BACK, GL_LINE);
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_BLUE));
glDrawArrays(GL_TRIANGLES, 0, 17);
                    

/assets/images/article/opengl/90feeff5-3090-450f-aca2-05e24b0ef5f0

多边形正面和背面采用不同的渲染模式

我们看到正面的是蓝色填充的,而背面的只有轮廓线。你可能已经注意到,为什么同时绘制的三角形,其中有3个正面,2个背面呢?这是因为,从惯例上来讲,多边形正面的顶点是逆时针排列的,而背面是顺时针排列的。如下图所示:

/assets/images/article/opengl/14ae3448-d4c8-4795-89ef-ca3462b93345

如果我将上面OpenGL渲染的图元沿着Y轴顺时针旋转180°后,就可以看到它们的背面。

    
worldMatrix.setToIdentity();
worldMatrix.translate(0.0f, -0.5f, -2.0f);
worldMatrix.rotate(-180.0f, 0, 1, 0);
setUniformValue(m_worldMatrixLoc, worldMatrix);
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_GREEN));
glPointSize(10.0f);
glDrawArrays(GL_POINTS, 0, 17);

setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_BLUE));
glDrawArrays(GL_TRIANGLES, 0, 17);
                    

/assets/images/article/opengl/0bf340cf-9beb-40af-aec7-0e6c2ff90e9f

它们的背面使用的轮廓线或者填充模式,与未旋转时完全相反。

H1

多边形面的反转和裁剪

H2

多边形的反转

通过OpenGL的函数glFrontFace控制多边形正面的判断方式,默认模式为GL_CCW,即多边形投影到窗口坐标系之后,顶点按照逆时针排序作为正面。如果模式为GL_CW,那么采用顺时针方向的面被认为是物体的正面。

    
glPolygonMode(GL_FRONT, GL_FILL);
glPolygonMode(GL_BACK, GL_LINE);
glFrontFace(GL_CW);
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_BLUE));
glDrawArrays(GL_TRIANGLES, 0, 17);
                    
                    

/assets/images/article/opengl/1322e14b-43c9-497e-a679-9ba9a979957b

我们可以看到,使用 glFrontFace(GL_CW)反转后,原来的正面变为背面,而背面变为正面。

H2

如何判断多边形的正面或背面

从专业角度来说,判断多边形的面是正面还是背面,需要依赖于这个多边形在窗口坐标系下的面积计算。而面积计算的一种方式是

$$ a=\frac{1}{2}\sum_{i=0}^{n-1}x_iy_{i\oplus1} - x_{i\oplus1}y_i $$ 其中\(x_i, y_i\)分别为边形的\(n\)个顶点中的第\(i\)个顶点的窗口坐标\(x和y\),而\(i\oplus1\)是公式\((i+1) \quad mod \quad n\)的缩写形式,其中mod表示取余数的操作。 假设我们设置为GL_CCW,那么当\(a > 0\)时,顶点所对应的多边形就是位于正面;否则它位于背面。以三角形作为例子,\(n=3, i的取值是[0, 2]\)。使用上面的公式,我们可以得到三角形的面积计算公式。 $$ a = \frac{1}{2}\Big[(x_0y_1-x_1y_0) + (x_1y_2 - x_2y_1) + (x_2y_0 - x_0y_2)\Big] $$

下图列出了两个三角形的共6个屏幕坐标,

/assets/images/article/opengl/d809ebe5-1c3d-4e35-8094-d3253dbf6176

依上述公式,我们可以计算得到

$$ a_1 = \frac{1}{2}\Big[\Big((-0.7)\times(-0.2)-(-0.6)\times 0.2\Big) + $$ $$ \Big((-0.6)\times 0.2-(-0.5)\times (-0.2)\Big) + $$ $$ \Big((-0.5)\times 0.2-(-0.7)\times 0.2\Big) \Big] $$ $$ =\frac{1}{2} \times 0.08 = 0.04 > 0 \quad 位于正面 $$ 同样的方法我们可以计算得到\(a_2=-0.04 < 0\),所以它位于背面。

H2

多边形的裁剪

对于一个由不透明且方向一致的多边形组成的,完全封闭的模型表面来说,它的所有背面的多边形是不可见的--它们永远会被正面多边形所遮挡。如果位于模型的外侧,那么可以开启裁剪(culling)来直接抛弃OpenGL中的背面多边形。与之类似,那么位于模型内部,那么只有背面多边形是可见的。如果需要指示OpenGL自动抛弃正面或者背面多边形,可以使用glCullFace()命令,同时通过glEnable开启裁剪。

    
// 旋转180°后三角行的背面
worldMatrix.setToIdentity();
worldMatrix.translate(0.0f, -0.5f, -2.0f);
worldMatrix.rotate(-180.0f, 0, 1, 0);
setUniformValue(m_worldMatrixLoc, worldMatrix);
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_GREEN));
glPointSize(10.0f);
glDrawArrays(GL_POINTS, 0, 17);

glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);

setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_LIGHT_BLUE));
glDrawArrays(GL_TRIANGLES, 0, 17);
                    

上面的代码表示在转换到屏幕空间渲染之前,设置了抛弃GL_BACK(背面)。因此你可以看到,在旋转180°之后,原来没有裁剪的话,那么背面显示的是轮廓线。当开启裁剪之后,背面的轮廓线也没有了,也就是说完全没有渲染,被裁剪了。

/assets/images/article/opengl/6e851afb-5309-43db-a009-e3461d6bd283

H1

图元的重启动

当需要处理较大的顶点数据时,我们可能会被迫执行大量的OpenGL绘制操作,并且每次绘制的内容总是与前一次图元的类型相同(例如GL_TRIANGLE_STIP)。

OpenGL支持在同一个渲染命令中进行图元重启的功能,此时需要指定一个特殊的值,叫做图元重启索引(primitive restart index),OpenGL内部会对它做特殊的处理。如果绘制调用过程中遇到了这个重启索引,那么就会从这个索引之后的顶点开始,重新开始相同图元类型的渲染。图元重启索引是通过glPrimitiveRestartIndex函数来完成的。

/assets/images/article/opengl/928e681c-7030-44f6-b5e7-0ad9bfe347ef

以上述的顶点数据为例,我们将8指定为图元重启索引。在三角形条带渲染的过程中,OpenGL会一直监控元素的数组中缓存中是否出现索引8,当这个值出现的时候,OpenGL不会创建一个顶点,而是结束当前三角形条带的绘制。下一个顶点(索引9)将成为一个新的三角形条带的第一个顶点,因此我们最终构建了两个三角形条带。

/assets/images/article/opengl/6863a545-259e-4715-8f78-ee2c228c6397

在使用图元重启时,我们需要创建GL_ELEMENT_ARRAY_BUFFER类型的缓存数组,这跟之前的GL_ARRAY_BUFFER不一样。GL_ELEMENT_ARRAY_BUFFER缓存的是顶点索引数据,而不是顶点数据本身。

    
// 顶点索引值
static const GLushort vertex_indices[] =
{
    0, 1, 2, 3, 4, 5, 6, 7,                 // 第一组三角形条带
    8,                                      // 这是重启的索引
    9, 10, 11, 12, 13, 14, 15, 16           // 第二组三角形条带
};

// 设置元素数组缓存
glGenBuffers(1, m_ebo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo[0]);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(vertex_indices), vertex_indices, GL_STATIC_DRAW);

//....

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo[0]);
setUniformValue(m_modelRenderColorLoc, RGBA_TO_VEC4(GL_COLOR_RED));
glLineWidth(2.0f);
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
// 开启图元重启
glEnable(GL_PRIMITIVE_RESTART);
glPrimitiveRestartIndex(8);
glDrawElements(GL_TRIANGLE_STRIP, 17, GL_UNSIGNED_SHORT, NULL);

// 如果没有开启图元重启,需要执行两次
glDrawElements(GL_TRIANGLE_STRIP, 8, GL_UNSIGNED_SHORT, NULL);
glDrawElements(GL_TRIANGLE_STRIP, 8, GL_UNSIGNED_SHORT, (const GLvoid*)(9 * sizeof(GLushort)));