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

渲染一个纯色三角形

撰写时间:2023-06-15

修订时间:2023-07-28

在上一堂课中,我们使用WebGL技术,将canvas的背景填充为黑色。

在这一堂课中,我们将渲染一个纯色的三角形。

WebGL渲染管道简介

GPU助力WebGL应用

设想一下,当我们要在屏幕上绘制出一个黄色三角形,应如何实现?

我们可以提供绘制这个三角形的各个顶点、及其相应颜色的信息,然后WebGL根据这些信息就足以在屏幕上绘制出期待的图形了。

思路是对的。但细节呢?使用类似于Canvas 2D的画线、画矩形、画圆、填充等方式吗?

WebGL作为3D渲染工具,要在有限的时间、有限的资源内有效地渲染各种复杂图像,追求高效是其终级目标。WebGL的一个伟大之处在于,Web应用从此可以在浏览器中令GPU (Graphics Processing Unit)这个高效的图形处理器为我们干活。WebGL应用程序得以充分利用GPU来完成各种复杂的图形渲染任务。

但由于GPU价格昂贵,内存有限,不可能一并将所有的渲染任务都放在GPU中运行。因此,将何种工作交付GPU来处理、如何向GPU快速地传递数据,成为WebGL应用必须要首先考虑的问题。

正基于此,WebGL应用程序所采取的方式与传统的Canvas 2D截然不同。WebGL采取的是服务器端 ─ 客户端模式。

服务器端 ─ 客户端模式

在典型的基于服务器端 ─ 客户端的Web应用中,客户端向服务器端发起一个请求,服务器端收到并解析该请求,从服务器端中提取数据库信息,构建动态页面的内容,然后再向客户端发回页面。这个过程,大部分都仅仅涉及到简单的文本处理,时间方面不是主要问题,因此根本无需GPU参与。但如果WebGL应用也采用这种方式,问题就大了。

在3D网络游戏中,每个玩家都向服务器发送自己的信息,然后等待服务器传回其他玩家的信息。如果WebGL应用先收集到每个玩家的信息,并在服务器端渲染出华丽的场景后,再向每个玩家传回该场景,由于渲染及网络传输的速度都很慢,这种方案肯定行不通。

实际上,WebGL虽然也采用了服务器端 ─ 客户端的模式,但与其让服务器独自渲染,WebGL将渲染任务交由各个客户端玩家来完成。服务器端只负责传输表示各个玩家及场景的以文本来表示的数据,每个玩家收到数据后,在各自电脑的GPU上完成渲染任务,这就大大减轻了服务器端的负担,并且,每个玩家根据自己显卡的情况,通过配置游戏的图像渲染方式,可以在华丽与速度之间取得较好的平衡。从这点上来讲,WebGL应用的服务器端指的不是服务器,而是负责渲染华丽图像的每个玩家的电脑。

WebGL应用的3种内存

WebGL应用中,共有3种内存数据。

第1种是位于客户端的数据。这种数据需通过网络向服务器端发送,速度很慢。

第2种是位于服务器端中的普通内存,这种内存可存储场景中大量的图像数据,这些数据在向显卡发送时,速度虽说比网络传递速度要快许多,但也需要一定的时间,无法实现瞬间渲染的效果,且因为无法做到同时一并向显卡发送所有数据,大部分的数据只能停留在服务器端的普通内存中。

第3种是在GPU存储及加工的数据,由于GPU与显示缓冲区都位于显卡上,并且GPU针对图像处理进行了专项大幅优化,因此传输、处理图像数据的速度大大加快,其缺点是内存、资源有限,必须有所取舍。

我们在编写WebGL应用代码时,通过JavaScript所指定的顶点等数据,属于第一种位于客户端的数据。我们需要将这种数据全部都复制到第二种内存即服务器端的内存中, WebGL称这类内存为WebGLBuffer,存储在此类内存中的对象称为Vertex Buffer Object (VBO), 由WebGL专门管理。至于第三种内存数据即GPU内存数据,WebGL通过着色器来进行管理。

着色器的本质

所谓着色器 (shaders),就是如何给特定像素着色的代码。它的内容很少,容量很小,可单独进行编译、链接并存放于GPU中。在渲染时,GPU直接运行这一部分的程序,就可高效地完成渲染任务。

着色器作为夹在服务器内存与最终渲染输出结果之间的模块,承担起在这两者之间建立传输管道、或称桥梁的职责。在WebGL 1.0中,着色器有一种存储修饰符 (storage qualifier) 为attribute的变量,专门用于接收服务器端普通内存传进来的数据。此外,还有两个专门的变量,gl_Positiongl_FragColor,分别负责向帧缓冲区 (frame buffer)输出经加工后的顶点位置信息及顶点颜色信息。

上面的客户端至服务器至帧缓冲器的管道示意图如下:

Client Server Pipeline

WebGL应用的本质是数据传送的问题

因此,WebGL应用的实质,不过是数据传送的问题,包括,如何从客户端向服务器端中的WebGLBuffer传送数据,服务器端如何从WebGLBuffer向着色器传送数据,如何从着色器向帧缓冲区传送数据的问题。当然,在数据传输的过程中,WebGL内部会根据需要,自动地在不同的阶段对数据进行相应的加工,再将加工后的数据传输到下一阶段进行加工,从而形成一个数据加工、传输的生产线,我们称为WebGL 渲染管道 (WebGL pipelines)。可想而知,这种加工、转换的过程很复杂,但令人欣慰的是,在绝大部分情况下,作为程序员,我们无需关心数据加工、转换的问题,我们只需关心数据如何传输的问题。正是由于WebGL有着健壮的渲染管道,从而大大减轻了3D应用程序员的负担,使得3D编程变成一种简单、优雅的工作。

概括来说,程序员需做以下事情:

  1. 准备着色器。
  2. 将着色器代码编译链接为可在GPU中使用的模块。
  3. 指定顶点、位置以及法线的数据信息,并复制到WebGL服务器内存中。
  4. 将要渲染的VBO绑定到ARRAY_BUFFER中,建立起VBO与着色器中的顶点属性的通道。
  5. 将着色器中的相应顶点属性激活为数组。
  6. 发送drawArrays命令。

准备着色器

准备着色器的过程可分为两部分:编写着色器代码与编译着色器。

编写着色器代码

WebGL 1.0的着色器代码,使用的是OpenGL ES 2.0版本的语法,而WebGL 2.0的着色器代码,使用的是OpenGL ES 3.0版本的语法。在本章中,使用的是OpenGL ES 2.0版本的语法。

WebGL应用的着色器的代码,既可存放于单独的文本文件中,也可直接放于网页中。着色器的代码部分一般不多,可优先考虑置放于网页中,这样编码及维护都较为方便。由于它也是一种可运行的程序,因此可像JavaScript的代码一样,放在script标签中。script标签并不总是默认被执行。对于着色器代码,只要不指定type="text/javascript"的类型,就不会被浏览器当作JavaScript代码而运行,从而安全地寄宿于网页中。当然,着色器代码另有运行环境。

<script id="vShader" type="x-shader/x-vertex"> attribute vec4 aPosition; void main() { gl_Position = aPosition; } </script> <script id="fShader" type="x-shader/x-fragment"> precision mediump float; void main() { gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0); } </script>

着色器的种类有多种,但只有顶点着色器 (vertex shader)与片断着色器 (fragment shader)是每个WebGL应用必不可少的。

idvshader的着色器是顶点着色器。在顶点着色器中,attribute表示变量aPosition是一个顶点属性 (vertex attribute),可用于接收WebGLBuffer的实例对象的数据(下面我们再详细阐述如何接收)。vec4表示其数据类型是含有4个成分的矢量,例如其值可为(0.0, 0.5, 0.0, 1.0)。对于表示位置信息的变量来说,这4个成分分别对应于(x, y, z, w)

如同C语言中的main函数,着色器的main函数也是着色器这类应用程序的入口。在main函数中,aPosition被赋值于gl_Position。如前所述,gl_Position是着色器中的一个特殊的变量,用于确定每个顶点的位置信息,在渲染阶段,此变量的值将被直接输出至帧缓冲区中。

短短的几行代码,即可完成从VBO中接收数据,再赋值于gl_Position,最后再将数据传输到帧缓冲区去渲染的过程。我们注意到,如何传输数据,是由WebGL渲染管道自动完成的。而上面的顶点着色器的代码,仅围绕如何设置gl_Position的值即可。这是着色器代码不同于C语言的地方:我们无需编写数据如何传输的完整代码,相反,我们只编写最核心的部分,剩下的代码由WebGL渲染管道替我们完成。

idfshader的着色器是片断着色器。在片断着色器中,precision mediump float表示:下面数据类型为float的数据,其精度为mediump (意为:medium precision),值域:(−214 , 214)。此外,还有highp (−262 , 262),以及lowp (−2 , 2)。一般取默认值即可。

该语句在顶点着色器中没有出现,那是因为顶点着色器中默认情况下已经设置为此,而片断着色器无此默认设置。

main函数中,gl_FragColor类似于顶点着色器中的gl_Position,也是着色器中的一个特殊的变量,用于确定每个顶点的颜色信息,在渲染阶段,此变量的值也会被直接输出至帧缓冲区中。在这里,我们使用硬编码的方式,将表示黄色颜色值的字面值vec4(1.0, 1.0, 0.0, 1.0)直接赋值给它。这样,不管顶点是什么,它们的颜色值都只能是黄色。

上面表示位置与颜色的值都可用vec4来表示,可见,vec4是一个通用数据类型 (generic data type),而变量aPosition则被称为通用顶点属性 (generic vertex attribute)。通用顶点属性是指,在着色器中,带有attribute标识符、且数据类型为通用矢量数据类型的变量。它们的作用,专用于在顶点着色器中接收位于GPU之外的WebGLBuffer的数据。

此时状态如下:

Shader Script Pipeline

注意,着色器的代码是被动地被调用的。作为程序员,我们只需按上面的格式写好代码就行,无需显式地调用这些代码。实际上,当我们发起渲染指令后,当WebGL需要确定每个顶点信息时,都会自动调用这些着色器代码。并且,每有一个顶点,就调用一次这两个着色器。这些细节由WebGL渲染管道控制,随着学习的深入,我们将会学习更多的渲染管道的知识。

WebGL先调用顶点着色器的代码,然后再调用片断着色器的代码,最后,将gl_Positiongl_FragColor的值输出至帧缓冲区,从而完成最终的渲染。

可见,我们在编写着色器时,只需关注如何设置gl_Positiongl_FragColor这两个变量的值即可。

编译着色器

这一步,我们将上面存储在两个script网页元素中的内容编译为着色器。

为化繁为简、化整为零,我们将实现这部分的功能放进一个名为loadShader的函数中:

function loadShader(shaderId) { let shaderScript = document.getElementById(shaderId); if (!shaderScript) { alert(`Error! Shader script ${shaderId} not found!`); return null; } let shaderType; if (shaderScript.type === "x-shader/x-vertex") { shaderType = gl.VERTEX_SHADER; } else if (shaderScript.type === "x-shader/x-fragment") { shaderType = gl.FRAGMENT_SHADER; } else { alert(`Error! Unkown shader type '${shaderScript.type}' for shader: '${shaderId}'`); return null; } let shader = gl.createShader(shaderType); gl.shaderSource(shader, shaderScript.text.trim()); gl.compileShader(shader); let compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS); if (!compiled) { let error = gl.getShaderInfoLog(shader); alert(`Error compiling shader ${shaderId}: ${error}`); gl.deleteShader(shader); return null; } return shader; }

首先,从相应的script标签读取内容,并根据该元素的type属性确定着色器的类型是顶点着色器 (VERTEX_SHADER) 还是片断着色器 (FRAGMENT_SHADER)。

然后,调用createShader方法,根据着色器类型创建着色器:

let shader = gl.createShader(shaderType);

加载文本内容并编译着色器:

gl.shaderSource(shader, shaderScript.text.trim()); gl.compileShader(shader);

编译后的结果将存储在着色器的内部状态中。我们可以通过getShaderParameter方法查看编译结果:

let compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS); if (!compiled) { let error = gl.getShaderInfoLog(shader); alert(`Error compiling shader ${shaderId}: ${error}`); gl.deleteShader(shader); return null; } return shader;

如果编译失败,则返回一个null值。否则,进一步调用getShaderInfoLog方法查看错误原因,并调用deleteShader删除着色器后,返回null值。

最后,如果编译成功,返回新创建的着色器。

上面一下子出现了好几个WebGL的方法,代码显得较长,但有较多部分是检查各环节是否有错误。而几个WebGL方法意义明确、逻辑清晰、相辅相成、一气呵成,反倒比较容易理解。而我们将这些方法都打包进一个名为loadShader函数后,就可以将这些繁杂的细节都隐藏在一个黑盒子中,需要时只需这样调用即可:

let vshader = loadShader('vShader'); let fshader = loadShader('fShader');

链接Program

WebGLProgram是专用于在GPU中运行的应用程序。着色器是WebGLProgram的内部子模块。WebGL使用WebGLProgram来统一管理、调用着色器,以及对外返回各个着色器的顶点属性及uniform属性 (uniform attribute)在WebGLProgram中的索引位置。这样用户就可以根据这些索引位置来访问相应的属性。

Uniform属性不同于顶点属性的地方在于:顶点属性是基于每个顶点的属性,对于每个不同的顶点,顶点属性的值都会改变。顶点属性只存在于顶点着色器中;而uniform属性在整个WebGLProgram中其值都不会改变,可存在于顶点着色器或片断着色器中。我们在后面的章节再详细介绍它。

使用WebGLProgram的关键步骤为:

  1. 创建WebGLProgram对象
  2. 将各个着色器添附进WebGLProgram对象
  3. 链接WebGLProgram对象
  4. 使用WebGLProgram对象

下面,我们将这几个关键步骤都打包进一个名为initProgram的函数中:

function initProgram(vShaderId, fShaderId) { const vertexShader = loadShader(vShaderId); const fragmentShader = loadShader(fShaderId); program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); let linked = gl.getProgramParameter(program, gl.LINK_STATUS); if (!linked) { let error = gl.getProgramInfoLog(program); alert(`Error in program linking: ${error}`); gl.deleteProgram(program); gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); return; } gl.useProgram(program); }

首先,我们先使用上节的loadShader函数来加载顶点着色器及片断着色器,然后创建一个WebGLProgram的对象并赋值于全局变量program,通过attachShader方法将这两个着色器都添附进该对象,接着通过linkProgram方法链接该对象,最后通过useProgram方法来使用该对象。

上面还有一些容错处理的代码。如同上节所示,在链接WebGLProgram后,我们可以通过调用getProgramParameter方法来检查结果是否null值。如果是null值,则调用getProgramInfoLog方法来查看错误信息,并及时删除WebGLProgram对象及相应的着色器。

如果链接成功,则调用useProgram方法来使用它。

回顾上一节的WebGL管道示意图:

Client Server Pipeline

此时,我们已经将着色器编译进WebGLProgram并进驻到GPU中,但尚未传送任何数据。下面,我们开始实现各阶段的数据传输功能,也即图中的箭头的指向。

从UserClient传送数据至WebGLBuffer

下面是实现相关功能的initVBOs函数:

function initVBOs() { let vertices = [ 0.0, 0.5, -0.5, -0.5, 0.5, -0.5 ]; vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); }

WebGL坐标系

我们的目标是在帧缓冲区中渲染一个三角形。因此需指定这个三角形的顶点坐标。

WebGL有多种坐标系。为简单起见,我们先粗略地认为,WebGL的坐标系是笛卡尔坐标系,X轴正向指向右边,Y轴正向指向上方,Z轴正向指向观察者。原点位于canvas的中间。因此,我们用下面的代码:

let vertices = [ 0.0, 0.5, // V0 -0.5, -0.5, // V1 0.5, -0.5 // V2 ];

声明了一个以三角形的3个顶点所组成的数组。其中,顶点V0在Y轴的正方向,V1在第三象限,V2在第四象限。

需注意的是,在3D坐标系中,任何一个多边形都有正反面之分。正面是朝向观察者的一面,反面是观察者无法看到的一面。为节省计算资源,在反面的细节许多时候不需要渲染出来,因此,WebGL需要明确任意多边形的正反面。默认情况下,以逆时针方向来指定的一系列顶点所构成的多边形为正面。因此,从一开始我们就应养成以逆时针方向来指定各个多边形的顶点位置,以让多边形的正面朝向我们,这样不容易出错。

细心的读者可能会注意到,上面每个顶点的坐标仅指定了X轴及Y轴的坐标值,并没有Z的轴的坐标值。没事,所缺失的Z的轴的坐标值最终会以默认值0.0来补上。我们现在还处于刚入门阶段,主要先学会应对2维的场景,因此,用平面坐标系来表示顶点的位置足够了。

另外,WebGLX轴及Y轴的最大值是多少?为何坐标值中出现0.5的值?默认情况下,观察者的位置位于坐标系Z轴数值为1.0的位置上,此时观察者的视野能看到X轴的值域范围为[-1.0, 1.0],Y轴的值域范围也为[-1.0, 1.0]。因此上面三角形的3个顶点均在X轴及Y轴的中间。WebGL不会限制坐标系各个轴的最大值与最小值,我们可以为多边形各个顶点指定任意的坐标值。而后,若要将整个场景拉入视野,观察者在Z轴可往后移动;若要看清某一细小部分,观察者在Z轴上往前移动即可。这些细节后面章节会讲到。

将顶点数据复制到WebGLBuffer

变量vertices的数据存在于客户端内存中,现在,我们需要将这组数据移到WebGL管理的内存中。

function initVBOs() { ... vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); }

这一部分代码的示意图如下:

bufferData

首先,createBuffer方法创建并返回一个WebGLBuffer对象,这种对象我们称之为VBOvertex buffer object顶点缓冲区对象,是存储于WebGL所管理内存中的、提供了顶点的位置、颜色等信息的对象),并赋值于变量vbo。此时,vbo的内存空间尚未有任何内容。

第二,通过bindBuffer方法将vbo绑定至ARRAY_BUFFER

第三,代码new Float32Array(vertices)根据数组vertices的内容创建了一个Float32Array的类型化数组。然后,调用bufferData将此类型化数组的内容复制到当前绑定至ARRAY_BUFFERvbo中。

bufferData方法的第2个参数要求传入类型化数组,如上面的Float32Array等。

注意bufferData方法是向当前绑定到ARRAY_BUFFERVBO传送数据。依赖于ARRAY_BUFFER的绑定的方法,除了bufferData外,还有下面要讲到的vertexAttribPointer方法。

不同的数据传输机制

至此,我们完成了WebGL管道示意图中第1条箭头所示的数据传输。此时,数据已经传输至服务器端内存。在数据传送至服务器内存后,客户端的vertices与之后的管道就不再发生联系了。

Client Server Pipeline

现在,如何实现示意图中第2条、第3条箭头所示的数据传输?

从用户的角度,WebGL只有两种数据传输机制:主动的数据传输及被动的数据传输。

第1条箭头的实现机制,也即我们在上一节中所调用的bufferData方法,是主动的数据传输机制,通过此方法,我们将vertices的数据,显式地、主动地传输至当前绑定至ARRAY_BUFFERVBO

而第2条、第3条箭头的实现机制,是被动的数据传输机制。我们不能再像上面一样自主决定,我要将什么数据传输至哪里。现在既然数据已经流转到WebGL所管理的内存中(在图中标记为WebGLServer的方框),如何传送数据就不需要我们操心了,WebGL会在恰当的时候,自动将数据从图中的WebGLBuffer传输至GPU,再传输至FrameBuffer。由于用户不参与到里面去,因此,对于用户来讲,这种机制是被动的数据传输机制。

WebGL为何要在这里就开始接管所有的工作?

主要是基于内存与效率的原因。我们目前的程序,在WebGL缓冲区中只有一个内容很少的VBO,因此,WebGL只需将此数据简单地复制到GPU中即可。但设想一下,如果某个应用程序,有几十个、上百个VBO,且每个VBO的内容都很多,那么,在每次渲染时,WebGL能否都先将它们都复制到GPU中以加快渲染速度呢?因为GPU价格昂贵,容量很小,因此这种简单、粗暴的方法不可行。在这种情况下,我们只能有选择性地将一小部分频繁使用的内容复制到GPU,而大部分的内容就呆在WebGL缓冲区中等候,等需要时再与GPU中的内容交换。因此,这涉及到一个很重要、但又很难实现的内存优化机制。

这种内存优化机制如果交由开发人员来实现,开发人员的负担就会较重。因此,WebGL至此就开始接管,由它在内部决定内存如何优化,以达到最高、最大的效率。而作为开发人员,只需在上一节中调用bufferData时,以最后一个参数来告诉WebGL,此VBO是否需要优化:

gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);

gl.STATIC_DRAW是一个枚举类型。它以下划线_为界,分为两部分,第一部分表示访问频率,第二部分表示访问的目的。

第一部分可取的值及其含义如下:

STREAM
数据只修改一次,且使用频率至多只有几次。
STATIC
数据只修改一次,且多次使用。
DYNAMIC
数据会不断地修改,且多次使用。

第二部分可取的值只有一个:

DRAW
数据可以被应用程序修改,且用于WebGL渲染等相关命令。

上面的代码gl.STATIC_DRAW告诉WebGL:该数据只会被修改一次,且会多次使用。虽然不同的WebGL实现有各自的优化算法,但如果对于上述数据,大部分的实现大概率会将其直接复制到GPU的内存中,从而实现内存与效率的优化。

以渲染命令为驱动的被动数据传输机制

那么,何时是恰当的时候WebGL在内部开始自动传输数据?

答案是当我们发布渲染指令后。但在发布指令之前,我们需要做两件事情。

定义数据传输规则

第一步,是需要告诉WebGL如何从VBOaPosition传输数据。

aPosition是一个只有4个浮点元素的数组,而变量vbo则是一个有6个元素的数组,它们不是一一对应的关系。且vbo的6个元素中,每2个元素为一个顶点的一组坐标值。因此,对于每次传输,只需传输2个元素就行了。定义这些传输规则,通过下面代码来实现:

gl.bindBuffer(gl.ARRAY_BUFFER, vbo); let loc = gl.getAttribLocation(program, 'aPosition'); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

其示意图如下:

vertexAttribPointer

先将vbo绑定到ARRAY BUFFER。然后,vertexAttribPointer方法做了以下5件事:

  1. ARRAY BUFFERaPosition连接。
  2. vbo中做好标记,每次传输只传输2个元素。在图示中以不同颜色标示。
  3. 数组中元素的类型是FLOAT
  4. 每次从数组中取一组坐标值时,数组中数据的间隔(stride)为0。也即该数组中均是连续的坐标值,无其他数据参杂其中。
  5. 将数组指针置于索引值为0的位置。

通过上面的代码,WebGL明确了数据传输源以及传输目的地,也知道了每次如何提取数据,以及从哪里开始提取数据。

实际上,WebGL将上述信息存储在特定的数据结构中,如果感兴趣,我们可以通过下面的代码来查看这些信息:

let vaInfo = {}; vaInfo.bindingBuffer = gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_BUFFER_BINDING); vaInfo.isArrayEnabled = gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_ENABLED); vaInfo.arraySize = gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_SIZE); vaInfo.arrayStride = gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_STRIDE); vaInfo.arrayType = gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_TYPE); vaInfo.isArrayNormalized = gl.getVertexAttrib(loc, gl.VERTEX_ATTRIB_ARRAY_NORMALIZED); vaInfo.currentVertexAttrib = gl.getVertexAttrib(loc, gl.CURRENT_VERTEX_ATTRIB);

将顶点属性激活为数组

第二步,将顶点属性aPosition激活为数组。

let loc = gl.getAttribLocation(program, 'aPosition'); ... gl.enableVertexAttribArray(loc);

enableVertexAttribArray方法将顶点属性激活为数组。

我们往往需要诸如顶点位置、顶点颜色等信息才能渲染一个顶点。因此在渲染渲染每个顶点时,我们需要向着色器的各个顶点属性同时传输多组数据。不是每个顶点属性在渲染顶点时都需要传输数据,只有被激活为数组的顶点属性才会在每次渲染顶点时传输数据。不被激活为数组中的顶点属性的数据在每次渲染顶点时不会被传输。因此,虽然我们的程序目前每次只需向aPosition一个顶点属性传输数据,也必须将该顶点属性激活为数组。

激活数组后,GPU中将创建一个aPosition数组,其数据由vbo填充。vbo数组中每个顶点只有2个成分,而aPosition有4个成分,不足部分由WebGL自动在成分缺失的位置以0.0的数值来填充。

enableVertexAttribArray

虽然上面的数组共有12个元素,但到了这一步,WebGL是这样识别的:共有3个顶点的数据,且每个顶点的数据都有固定的4个成分。或简而言之,该数组共有3组数据。

发送渲染指令

现在,我们可以向WebGL发送渲染指令了:

gl.drawArrays(gl.TRIANGLES, 0, 3);

上述语句的意思为,从各个被激活的顶点属性数组中,从第0组开始,分别取出3组数据,用于渲染三角形。而当前程序我们只用到了aPosition数组,因此也只有这个数组的数据被提取。

aPosition数组存储的是顶点坐标信息,图中每一种颜色的元素代表一个顶点的坐标值,因此,上述语句对于aPosition数组来讲就意味着,从第0个顶点开始,共取3个顶点的位置信息,用于构建三角形。由于总共只有3个顶点,因此就只能构建一个三角形。

因为这是基于顶点的操作,由于上面语句共涉及3个顶点,因此在内部,WebGL分4步完成此指令:

第1步,从数组中取出第一个顶点的数据,连同硬编码的颜色信息(下节详述),传输至FrameBuffer,从而点亮第一个黄色的像素。见下图。

1st step of drawArrays

第2步,从数组中取出第二个顶点的数据及同颜色信息,传输至FrameBuffer,点亮第二个黄色的像素。

2nd step of drawArrays

第3步,从数组中取出第三个顶点的数据及同颜色信息,传输至FrameBuffer,点亮第三个黄色的像素。

3rd step of drawArrays

第4步,将三个像素连成一个三角形。

4th step of drawArrays

当然,完成这4步后,WebGL管道还将继续进行其他操作,如填充此三角形的栅格化、裁剪等,这些都有待于后面详细讲解。

至此,一个黄色三角形就被渲染出来了。

本节完整代码

下面列出上面各节讲到的完整代码:

<!DOCTYPE html> <html> <head> <title>Yellow Triangle</title> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="/css/FullScreenCanvas.css" /> <script id="vShader" type="x-shader/x-vertex"> attribute vec4 aPosition; void main() { gl_Position = aPosition; } </script> <script id="fShader" type="x-shader/x-fragment"> precision mediump float; void main() { gl_FragColor = vec4(1.0, 1.0, 0.0, 1.0); } </script> <script type="module"> let gl, program; let vbo; init(); function init() { initContext(); initProgram('vShader', 'fShader'); initVBOs(); configContext(); render(); } function initContext() { let canvas = document.getElementById('webgl-canvas'); gl = canvas.getContext("webgl"); } function initProgram(vShaderId, fShaderId) { const vertexShader = loadShader(vShaderId); const fragmentShader = loadShader(fShaderId); program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); let linked = gl.getProgramParameter(program, gl.LINK_STATUS); if (!linked) { let error = gl.getProgramInfoLog(program); alert(`Error in program linking: ${error}`); gl.deleteProgram(program); gl.deleteShader(vertexShader); gl.deleteShader(fragmentShader); return; } gl.useProgram(program); } function loadShader(shaderId) { let shaderScript = document.getElementById(shaderId); if (!shaderScript) { alert(`Error! Shader script ${shaderId} not found!`); return null; } let shaderType; if (shaderScript.type === "x-shader/x-vertex") { shaderType = gl.VERTEX_SHADER; } else if (shaderScript.type === "x-shader/x-fragment") { shaderType = gl.FRAGMENT_SHADER; } else { alert(`Error! Unkown shader type '${shaderScript.type}' for shader: '${shaderId}'`); return null; } let shader = gl.createShader(shaderType); gl.shaderSource(shader, shaderScript.text.trim()); gl.compileShader(shader); let compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS); if (!compiled) { let error = gl.getShaderInfoLog(shader); alert(`Error compiling shader ${shaderId}: ${error}`); gl.deleteShader(shader); return null; } return shader; } function initVBOs() { let vertices = [ 0.0, 0.5, -0.5, -0.5, 0.5, -0.5 ]; vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); } function configContext() { gl.clearColor(0, 0, 0, 1); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); let loc = gl.getAttribLocation(program, 'aPosition'); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(loc); gl.drawArrays(gl.TRIANGLES, 0, 3); } </script> </head> <body> <canvas id="webgl-canvas">Your browser does not support <code>canvas</code>!</canvas> </body> </html>

运行此应用,我们将会看一个黄色的三角形呈现在浏览器中。

三个问题。一是仅渲染一个三角形就需要这么长的代码,WebGL的框架设计为何这么臃肿?二是三角形很模糊。三是当改变浏览器大小时,三角形会随之而变形,不能保持原有的比例。这些问题,我们将在下一节中分析并予以解决。

本章所学回顾

本章新学到的API有:

类别方法作用
着色器createShader创建着色器
shaderSource加载着色器源代码
compileShader编译着色器
getShaderParameter获取着色器参数
getShaderInfoLog获取着色器错误代码
deleteShader删除着色器
programcreateProgram创建program
attachShader添附着色器
linkProgram链接program
getProgramParameter获取program参数
getProgramInfoLog获取program错误代码
deleteProgram删除program
useProgram使用program
VBOcreateBuffer创建VBO
bindBuffer绑定VBO
bufferDataVBO填充数据
顶点属性vertexAttribPointer绑定特定VBO与顶点属性
enableVertexAttribArray激活顶点属性数组
getAttribLocation获取顶点属性的索引值
渲染命令drawArrays从数组数据渲染基本图元

本章新接触的API较多,共有20个方法,但经分类后,共涉及5大类。其中,涉及着色器与program的API最多,而涉及这一类的API的代码基本是固定的,很少有变化。如果能将这些代码都包装起来,学习的负担就会减轻许多,而这留待下一章解决。

不用管具体的API,只需从大局上理解WebGL的渲染管道,本章内容就可以较为轻松地掌握。

本章的重点,也是需要读者用心掌握的,均包装在initVBOsrender两个函数中。

实际上,本章是学习WebGL的最大门槛,只要掌握了这一章,后续内容均显得较为简单了。

参考资源

  1. opengl.org
  2. WebGL Home
  3. WebGL 1.0 API Quick Reference Card
  4. WebGL 1.0 Specification
  5. glBufferData
  6. glVertexAttribPointer