[中级篇]如何绘制抗锯齿线?

2018/11/24 Map

[中级篇]如何绘制抗锯齿线?

导语 本篇文章谈谈具体在OpenGL环境下实现抗锯齿线的渲染,包含数据输入到绘制成图的所有核心代码以及思路~

一、给你一段坐标串

1. 事先准备

1.1 坐标串

​ 要想绘制一条自己能够控制的线,怎么能不提供原始坐标串?因此,事先准备好需要渲染的坐标串,保证第一步。数据源可以是二维的点数组。

1.2 样式

​ 一个线状要素想要任性的绘制,应该需要对线头和线相交处的处理。 本部分参考自:http://wiki.jikexueyuan.com/project/canvas-wiki/6.html

1.LineCap(线头)

lineCap 定义上下文中线的端点,可以有以下 3 个值。

butt:默认值,端点是垂直于线段边缘的平直边缘。 round:端点是在线段边缘处以线宽为直径的半圆。 square:端点是在选段边缘处以线宽为长、以一半线宽为宽的矩形。

img

2.LineJoin(线交接)

​ lineJoin 定义两条线相交产生的拐角,可将其称为连接。

miter:默认值,在连接处边缘延长相接。miterLimit 是角长和线宽所允许的最大比例(默认是 10)。 bevel:连接处是一个对角线斜角。 round:连接处是一个圆。

img

​ 这里是不是忽略了线宽,没有线宽搞毛啊?可以看上一篇文章,由于绘制线其实是将线理解为三角形构成的面进行绘制的,地图在进行放大缩小的时候,会不断的更改同一个线状要素的线宽,所以为了保证地图在放大缩小的时候原始数据的复用,会把线宽放到顶点着色器中进行处理,这个后面会讲到。 ​ 好,原始数据准备完毕,有没有很简单,甚至,我想什么时候都要用圆角端点,圆角连接处。那么,你的准备工作就可以只是坐标串输入了。

2. 插播概念

​ 你只用输入坐标串,剩下的就交给OpenGL了,为了交代的彻底,必须得按照OpenGL要求的来不是。这里需要谈到一些OpenGL的概念。 参考自:https://learnopengl-cn.github.io/

2.1 顶点输入:

​ 由于OpenGL是在3D空间中工作的,而我们渲染的是一个2D三角形,我们将它顶点的z坐标设置为0.0。这样子的话三角形每一点的深度都是一样的,从而使它看上去像是2D的。 ​ 因此,顶点数组一般长这样:

GLfloat vertices[] = {    -0.5f, -0.5f, 0.0f,     0.5f, -0.5f, 0.0f,     0.0f,  0.5f, 0.0f};

2.2 顶点缓存对象(Vertex Buffer Objects, VBO):

​ 它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。

​ 顶点缓冲对象就像OpenGL中的其它对象一样,这个缓冲有一个独一无二的ID,所以我们可以使用glGenBuffers函数和一个缓冲ID生成一个VBO对象:

GLuint VBO;
 glGenBuffers(1, &VBO);

​ OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。我们可以使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上:

glBindBuffer(GL_ARRAY_BUFFER, VBO);

​ 从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

​ glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。

​ 第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:

GL_STATIC_DRAW :数据不会或几乎不会改变。 GL_DYNAMIC_DRAW:数据会被改变很多。 GL_STREAM_DRAW :数据每次绘制时都会改变。

2.3 索引缓冲对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)

​ 举个例子,如果我们绘制的不是三角形而是一个矩形,那么需要的顶点集合如下:

GLfloat vertices[] = {    // 第一个三角形
     0.5f, 0.5f, 0.0f,   // 右上角
     0.5f, -0.5f, 0.0f,  // 右下角
     -0.5f, 0.5f, 0.0f,  // 左上角
     // 第二个三角形
     0.5f, -0.5f, 0.0f,  // 右下角
     -0.5f, -0.5f, 0.0f, // 左下角
     -0.5f, 0.5f, 0.0f   // 左上角};

​ 很明显,右下角和左上角数据存在冗余,解决方案是:只储存不同的顶点,并设定绘制这些顶点的顺序。 ​ 索引缓冲对象的工作方式正是这样的。和顶点缓冲对象一样,EBO也是一个缓冲,它专门储存索引,OpenGL调用这些顶点的索引来决定该绘制哪个顶点。 ​ 那么,绘制一个矩形需要的数据是这个样子:

GLfloat vertices[] = {    0.5f, 0.5f, 0.0f,   // 右上角
     0.5f, -0.5f, 0.0f,  // 右下角
     -0.5f, -0.5f, 0.0f, // 左下角
     -0.5f, 0.5f, 0.0f   // 左上角};
 
 GLuint indices[] = { // 注意索引从0开始! 
     0, 1, 3, // 第一个三角形
     1, 2, 3  // 第二个三角形};

创建索引缓冲对象:

GLuint EBO;
 glGenBuffers(1, &EBO);

​ 与VBO类似,我们先绑定EBO然后用glBufferData把索引复制到缓冲里。同样,和VBO类似,我们会把这些函数调用放在绑定和解绑函数调用之间,只不过这次我们把缓冲的类型定义为GL_ELEMENT_ARRAY_BUFFER。

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
 glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

​ 要注意的是,我们传递了GL_ELEMENT_ARRAY_BUFFER当作缓冲目标。最后一件要做的事是用glDrawElements来替换glDrawArrays函数,来指明我们从索引缓冲渲染。使用glDrawElements时,我们会使用当前绑定的索引缓冲对象中的索引进行绘制:

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
 glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

​ 第一个参数指定了我们绘制的模式,这个和glDrawArrays的一样。第二个参数是我们打算绘制顶点的个数,这里填6,也就是说我们一共需要绘制6个顶点。第三个参数是索引的类型,这里是GL_UNSIGNED_INT。最后一个参数里我们可以指定EBO中的偏移量(或者传递一个索引数组,但是这是当你不在使用索引缓冲对象的时候),但是我们会在这里填写0。

3. 事成之后

​ 好了,废话说了这么多,其实无非就是需要给我们数据源,处理出顶点数据和索引数据,问题来了,怎么生成这些顶点和索引数据呢?

3.1 处理线头

​ 还是先扯废话,从《【译】OpenGL环境下抗锯齿线的绘制 》这篇文章可以找到,我们组织顶点数据的时候是没有线宽概念的,但是又需要组织三角形去渲染,那么线宽是啥?在这里,组织顶点但凡涉及到长度,都采用单位向量。

3.1.1 Square线头

​ 那么,对于0、1号点,通过向量减法,可以得到第一条线段的方向向量a。 有了方向向量a,那么垂直于当前向量的单位向量也很容易得到。

img

​ 如图所示,可以很容易的到0,1,2,3,4,5号顶点。(向量0-2可以由0-1和0-3相加 ) ​ 那么顶点索引可以为

GLuint indices[] = {0,1,2,0,2,3,0,3,4,0,4,5,
 }

​ 是不是很简单,我们的Square线头就出来了,默认的不用说,不用做任何处理,那么难的就是,round线头怎么处理呢?

3.1.2 Round线头

​ 没有绝对的圆弧,只有相对的圆弧,当时PI的值不就是使用无限接近的多边形进行逼近得到的么?

​ 所以,在这里,可以通过设置一个分段值,然后使用向量0-1和0-5的角度也就是PI进行分段。比如分4段,那么分段值为0,45,90,135,180。

​ 那么将半圆切割后,就需要分别进行旋转得到最终的顶点数据。 旋转矩阵是

R=[cos(a) -sin(a) sin(a) cos(a)]

​ 将0-1向量分别和旋转矩阵进行运算,这里的a值就是上面的值啦,然后根据这些顶点坐标和0号点建立三角形,于是,我们的圆形线头就出来啦~ ​ 假装此处有效果图。

3.2 处理线交接处

​ 先看看最简单的,miter情况下的处理。

3.2.1 miter

​ 如图,由于AB的方向向量,我们可以很容易的到a1,a2向量,由于BC的方向向量,我们也可以很容易的到b1,b2向量。

img

​ 好,那么怎么得到B-3向量和B-2向量呢?很幸运,这里垂直于线段的向量都是单位向量,因此对a1,b1进行向量加,得到c1,对c1进行归一化,然后除以c1和a1点乘(由于都是单位向量,点乘结果就是cos值)的值,就可以得到B-3向量啦,同理也可以得到B-2向量。

3.2.2 bevel

img

​ 如图,其实bevel也没多难,有了miter的基础,无非不就是上面的向量不用那么麻烦,直接采用A-B,B-C的垂直单位向量和B点不久搞定了,不过下面处理还是得和miter一样的~

3.2.3 round

​ 又是Round,好吧,为了达到优美的效果,圆弧肯定是比干巴巴的直线好看的。

img

​ 好了,有了前面的基础,我们可以很容易得到a1,a2向量,那么由于刚好又是单位向量,a1,a2的夹角就很容易得到了,我们对这个夹角做些拆分,通过旋转矩阵,不就可以得到这个圆弧的所有顶点数据了么?

​ 好了,有了圆弧上的顶点,就有了对该圆弧细分的三角形,还需要什么呢?组织顶点,怎么组织顶点,各位按照自己的喜好吧~

3.2 处理线尾

​ 一下就到了线尾,想想有点小激动,这不是和线头一样么?但是有头有尾嘛,虽然是废话,还是得说,确实一样。

二、设置着色器

​ 搞了半天,线呢?我也想知道,线呢?

img

img

​ 从这两幅图可以发现,我们仅仅做到了第一步。。。原来后面还有顶点处理,片元处理,纹理处理等等,好吧,一个个来说吧。

1.顶点着色器

img

1.1看看着色器

version 330 core
 layout (location = 0) in vec3 position;
 void main(){
     gl_Position = vec4(position.x, position.y, position.z, 1.0);
 }

​ 看个例子,这个是一个很基础的例子,表面最后顶点的位置由输入的position决定,确实很简单,但在这里主要是说明整个着色器怎么写。

​ 使用in关键字,在顶点着色器中声明所有的输入顶点属性(Input Vertex Attribute)。现在我们只关心位置(Position)数据,所以我们只需要一个顶点属性。GLSL有一个向量数据类型,它包含1到4个float分量,包含的数量可以从它的后缀数字看出来。由于每个顶点都有一个3D坐标,我们就创建一个vec3输入变量position。我们同样也通过layout (location = 0)设定了输入变量的位置值(Location)你后面会看到为什么我们会需要这个位置值。

​ 当前这个顶点着色器可能是我们能想到的最简单的顶点着色器了,因为我们对输入数据什么都没有处理就把它传到着色器的输出了。在真实的程序里输入数据通常都不是标准化设备坐标,所以我们首先必须先把它们转换至OpenGL的可视区域内。

​ 既然可以这样,我们是不是可以输入坐标后,在这里去做线宽的处理啊,对啊,怎么做呢?

1.2编译着色器

​ 我们首先要做的是创建一个着色器对象,注意还是用ID来引用的。所以我们储存这个顶点着色器为GLuint,然后用glCreateShader创建这个着色器:

GLuint vertexShader;
 vertexShader = glCreateShader(GL_VERTEX_SHADER);

​ 下一步我们把这个着色器源码附加到着色器对象上,然后编译它:

glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
 glCompileShader(vertexShader);

​ glShaderSource函数把要编译的着色器对象作为第一个参数。第二参数指定了传递的源码字符串数量,这里只有一个。第三个参数是顶点着色器真正的源码,第四个参数我们先设置为NULL。

1.3高级着色器

GLfloat vertices[] = {    // 位置              // 垂直向量
​      0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,  
​     -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   
​      0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    };

​ 也许上面处理数据后,我们的顶点数据是这个样子的,现在数据多了,我们怎么发送到顶点着色器呢?

#version 330 core
 layout (location = 0) in vec3 position; // 位置变量的属性位置值为 0
 layout (location = 1) in vec3 vertical;    // 垂直向量
 uniform float width; // 在OpenGL程序代码中设定这个变量
 out vec3 ourDis; // 向片段着色器输出一个距离
 void main(){
     gl_Position = vec4(position, 1.0);
     ourDis = vertical*(***); // 通过垂直向量计算颜色
 }

​ 好,又有一个顶点着色器了,这个好复杂。一个个来,首先,layout标识符可以区分输入数据,这里可以区分位置变量和垂直向量。

而uniform呢 ​ Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,但uniform和顶点属性有些不同。首先,uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。 ​ 所以,这里用来处理宽度再合适不过了。

​ 那么,这里还有一个out,out用来干嘛呢?之前的文章有讲过,片元着色器可以接受来自顶点着色器的值,这里设置垂直向量的地方距离最远,片元着色器会根据一条边的距离进行插值,如果alpha值根据距离有关,距离两端越近,alpha逼近于0,那么不就可以实现渐变的效果了么?

​ 说了这么多,layout怎么用呢?

img

​ 知道了现在使用的布局,我们就可以使用glVertexAttribPointer函数更新顶点格式,

// 位置属性
 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
 glEnableVertexAttribArray(0);
 // 垂直向量属性
 glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
 glEnableVertexAttribArray(1);

​ 观察参数,第一个参数就代表layout的位置,第二个参数代表需要更新元素的个数,第三个参数代表数据类型,第四个代表是否归一化,第五个参数代表步长,第六个参数则代表起始偏移量。

2.片元着色器

img

2.1看看着色器

#version 330 core
 in vec4 vertexColor;// 从顶点着色器传来的输入变量(名称相同、类型相同)
 out vec4 color; // 片段着色器输出的变量名可以任意命名,类型必须是vec4
 void main(){
     color = vertexColor;
 }

这个就是一个最基本的片元着色器。如果我们在顶点着色器中声明了一个vertexColor变量作为vec4输出,并在片段着色器中声明了一个类似的vertexColor。由于它们名字相同且类型相同,片段着色器中的vertexColor就和顶点着色器中的vertexColor链接了。如果我们在顶点着色器中将颜色设置为深红色,最终的片段也是深红色的。

创建一个片元着色器很简单

GLuint fragmentShader;
 fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
 glShaderSource(fragmentShader, 1, &fragmentShaderSource, null);
 glCompileShader(fragmentShader);

代码应该很清楚,我们把着色器附加到了程序上,然后用glLinkProgram链接。

glUseProgram(shaderProgram);

在glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用这个程序对象(也就是之前写的着色器)了。 对了,在把着色器对象链接到程序对象以后,记得删除着色器对象,我们不再需要它们了:

glDeleteShader(vertexShader);
 glDeleteShader(fragmentShader);

#version 330 core
 
 in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)
 in vec3 ourDis;// 从顶点着色器传来的距离
 out vec4 color; // 片段着色器输出的变量名可以任意命名,类型必须是vec4
 void main(){
     color = vertexColor;
     color.a = ourDis*(若干处理)
 }

于是,我们就有了一根线,不也就是有点圆角,方头,或者偶尔抗个锯齿么?

那不就够了么~

写篇文章不容易,欢迎交流,目前菜鸟一枚,潜心研究矢量渲染算法中。

Search

    Table of Contents