标注顶点信息
撰写时间:2023-08-04
修订时间:2026-04-13
由于缺乏在canvas中标注顶点的功能,我们很难看出各顶点在canvas中的精准位置及名称等信息。
在本章中,我们实现一个很实用的功能:在canvas中标注出各顶点序号及其位置信息。此功能可方便地帮助我们声明及修改各个图元的位置,从而能够较为轻松地渲染出精致而精准的图像。
实现思路
我们可以将span标签以浮动的方式在canvas上显示。
重点及难点在于两种渲染方式有不同的坐标系,因此需要将WebGL的NDC坐标系转换为Canvas 2D坐标系。
此外,我们可以针对VAO进行操作,取出其顶点位置的VBOs,再将这些VBOs的原始顶点位置数据提取出来。
因涉及多个类之间的相互配合,本章另一个核心内容就是通过应用设计模式来完善类与类之间交互关系,尽量减少类与类之间的依赖与耦合。
WebGLUtils-v7.js
WebGLUtils-v7.js这次改动的地方较多,共有5个方面,但均是小改动。
添加一个GLUHolder类
从现在开始,除了WebGLUtils类,以及之前使用过的GLColors类,我们将根据应用程序所需,逐渐开发各个相应的类,将类的职责分开,让它们各司其职,只完成自己份内应做的工作,从而实现应用程序的模块化设计,使应用程序更加强壮、稳健,也更易于维护。
第一,先导入两个辅助工具类。
这两个类下面再详细论述。
第二,添加一个GLUHolder类。
从前面各个例子中看到,glu是用得较多的一个变量。而各个新开发的类,有时也会不可避免地会使用到该变量。因此,我们新建了GLUHolder类,该类通过静态属性的方式,向外提供访问glu的机会。在需要引用其的各个类中,只需使用下面代码即可:
而完成赋值工作的代码放在WebGLUtils类的构造方法的最后一行:
这样,在创建WebGLUtils类的一个实例后,将该实例的引用通过GLUHolder的静态set方法自动完成赋值工作。
此外,删除WebGLUtils-v7.js最后一行:
上面的代码显示出,我们已经改为分别导出GLUHolder类及WebGLUtils类。
修改进入全屏状态的元素
之前,运行应用后,当我们输入回车键时,我们设置为canvas标签将进入全屏状态。这在之前没有任何问题,因为它是当时网页的body中的唯一一个子元素。
但由于我们要借助于自动创建各个span标签来完成顶点位置的标注工作,按之前的设置,在canvas进入全屏后,其他元素将自动隐藏,导致全屏状态下的标注信息消失。解决方法是为canvas元素添加一个父节点,然后将新创建的众多span元素,也归置于此父节点下,然后让此父节点进入全屏状态。这里相应的代码修改如下:
记住bindingTarget
每个VBO及IBO都属于WebGLBuffer类,在使用时都需要绑定至特定的目标,我们要创建的新类可能要针对WebGLBuffer统一操作,因此需辨别它们相应的绑定目标。我们将它们自己的绑定目标存储于各自的bindingTarget属性中。
让VAO持有各个VBOs
在创建VAO时,我们传入多个VBOs,这样WebGL就能记住各个VBOs的状态。但我们尚不能从VAO中方便地提取各个VBO,因此,我们可以通过为VAO添加一个vbos属性,让其记住所有相关联的VBOs。
修改createVAO方法如下:
这样,VAO就持有了相关联的VBOs的所有实例。
标注顶点位置信息
在WebGLUitls类的末尾添加markVerticesPos方法。
该方法是本章内容的核心部分。首先,根据传入的vao参数,找出其与aPosition绑定的verticesVBO,接着调用BufUtils的To2DArrays静态方法,将其内部的数组数据转换为一个二维的数组。例如,将格式为
这样的一维数组,转换为以下格式的二维数组:
在二维数组中,每个子元素都是一个顶点的坐标值数组,不仅直观,也很方便后续操作。
最后,将此二维数组继续传递给CanvasUtils的markVerticesPos方法,以在Canavs中将这些数据在相应位置标注出来。
注意到CanvasUtils采用了设计模式中的单例模式,通过调用其静态属性instance返回单例,这样,不仅方便调用,也确保了多个调用之间均访问同一实例。
同时,WebGLUtils类的markVerticesPos方法调用了CanvasUtils的同名方法,这是设计模式中门面模式的典型应用。这样设计,主要基于两点考虑,一是隐藏实现细节,二是增加多样性。如果需要对VAO操作,则调用WebGLUtils类的该方法;如果针对通用的二维数组操作,则调用CanvasUtils的该方法。在本章最终的例子中,将看到这两个方法均得到了调用。
此外,对比WebGLBufferUtils类与CanvasUtils类,前者的各个方法没有使用到需保存为自身状态的变量,因此各个方法以静态方法实现;而后者用到了保存为自身状态的变量,则必须先经初始化,但与其让客户端随意地调用其构造函数来创建各个实例,它以单例模式提供了一致的调用接口及统一的状态。
上面的代码看似很简单,但实际上在后台做了大量的工作,只不过是将这些工作分别交付给WebGLBufferUtils类及CanvasUtils类来完成。这也是设计模式中代理模式的体现。
为WebGL应用创建一个显示信息的面板
在开发WebGL应用时,我们有时需要及时查看相应的类或对象的状态。如果能有一个固定定位于左上角的面板,能随时打印出相关对象信息,无疑能直到较大的帮助作用。本节利用本站自行开发的PageConsole,针对一个WebGL应用的特点进行了定制,从而达到此目的。
目标与要求
以下是其效果图:
此面板对象有以下特点:
- 能自动固定位于一个WebGL应用的左上角。
- 当打印信息过多时,能自动出现滚动条,但区域应动态束缚在浏览器的视口范围内。
- 支持暗黑颜色主题。
- 所有内容置于一个顶部的折叠框中,以方便随时关闭整个面板。
- 当出现横向滚动条时,最后一行的内容将被横向滚动条遮挡而无法操作,故最下边一行用一个灰色的文本来作为占位。
- 使用简单,与原有的PageConsole的用法高度兼容一致。
- 允许在应用程序中多次独立调用以输出信息。
- 每输出一行信息时,自动将灰色占位行调整为最后一行。
- 不影响WebGL应用进入全屏的效果。
- 与其他类保持最低限度的耦合度,不管是否使用,均不影响原WebGL应用的功能。
唯一的要求是,如上节所示,将canvas置于一个id为canvas-container
的div元素之下。
具体实现
CSS文件
为实现上述所有目标,首先,创建一个名为FullScreenCanvas.css文件,其内容如下:
上述设置,为一个单页窗口Web应用正确设置滚动区域所必需。
输出面板对应于上面的id为pc-container
的div。
上面还为以后涉及到的WebGL应用的多视口设置了基本的边框。这样,以后每个WebGL应用均可统一使用该样式。
JavaScript文件
创建一个名为pc-panel.js的文件,内容如下:
函数initStyles为应用CSS创建相应的前提条件,并加载必要的CSS文件。
函数initDOMs自动创建并插入pc-container
容器,并在此之上挂载PageConsole的一个实例,为最顶层折叠把手设置标题,并添加文本占位行。最后一行语句的作用在于,当在客户端每输出一条信息时,该信息将自动被包裹进最顶层折叠把手之下。
此时还存在一个问题。即当在客户端调用诸如pc.log('Hello')的信息时,这些信息均应处于最后的文本占位行之上。因此,在函数extendPCFunctions中,我们使用代理机制,对pc的log方法进行了拦截,先检查并打开面板,接着打印信息,最后将文本占位行调整为最后一行。此外,还为panelPC设置了setGroupTitle以让客户端有机会修改为自定义标题。
最后,只需导出panelPC。
客户羰调用
在客户端,只要调用一行语句:
就足够了。
基于上面的几点需求,panelPC与普通的pc不大一样,主要是要求输出信息自动归置于最顶层折叠容器把手之下,以及自动调整最后一行的文本占位行。由于使用代理机制,且在导入时改名为pc,则客户端使用体验与普通pc将完全一致。
运行pcpanel-usage.html,除正常渲染图像之外,页面左上角多出一个面板可用于打印各类所需信息。
从VBO获取数据
获取数据的两种方式
要获取一个VBO的数据,共有2种方法。
客户端
第一种方法是从客户端获取。
之前,我们先在客户端以数组的形式指定顶点位置数据,然后再据此创建一个VBO:
vertices即是我们所需的顶点位置数据。但我们前面说过,客户端所使用的vertices的数据,在创建了VBO、调用gl.bufferData方法将vertices填充VBO并传输至WebGL Buffer后,vertices即与WebGL服务器断开了联系。在后续进程中,除非我们让vbo显式地存储该数组的数据,否则,我们不能再次访问vertices的数据。我们的原则是,如果有特定机制可以计算出、可以取出相应的数据,绝不随意地、过多地加重一个VBO的负担。
其次,vertices仅是一个扁平的一维数组数据,且缺乏更多的诸如共有多少个顶点、每个顶点由多少个元素组成、每个元素的数据类型是什么等信息。在某些需要这些信息的场合下,vertices无能为力。
WebGLBuffer对象
第二种方法是从VBO中提取顶点位置数据。
运行WebGLBuffer-info.html,除正常渲染图像之外,在页面左上角还打印出一个VBO的信息。
可以看出,VBO是WebGLBuffer的一个实例,而WebGLBuffer的prototype为Object,除了我们自行添加的属性之外,WebGLBuffer没有任何可以直接访问的属性。
好消息是,在WebGL 2.0中,我们可以调用gl的getBufferSubData方法,直接从GPU内存中读取一个VBO的信息。
getBufferSubData原型
getBufferSubData方法的原型如下:
- GLenumtarget
- GLintptrsrcByteOffset
- [AllowShared] ArrayBufferViewdstBuffer
- unsigned long long[dstOffset = 0]
- GLuint[length = 0]
- target
- 指定所绑定的目标。可为gl.ARRAY_BUFFER或gl.ELEMENT_ARRAY_BUFFER。
- srcByteOffset
- 指定源缓冲区的开始偏移值,以字节为单位。
- dstBuffer
- 指定存放数据的目标缓冲区,类型为类型化数组。
- [dstOffset = 0]
- 复制到目标缓冲区的哪个数组元素的索引位置。可选。默认值为0。
- [length = 0]
- 要读取的数组元素数量。可选。默认值为0。
- 当length的值为0时,要读取的数组元素数量为
dstBuffer.length - dstOffset。也即,从目标缓冲区起始位置开始,只要目标缓冲区有空间,则一直复制。
getBufferSubData方法详解
光看该方法原型,我们还是难以理解如何正确地调用。
运行getBufferSubData-1.html,左上角面板将打印如下信息:
从界面上看,分三大类打印了各种信息。
在应用程序的initVAO函数中,有以下代码:
clientVertices是在客户端所指定的顶点位置数据,verticesVBO是传输至WebGL服务器的VBO。界面先格式化打印clientVertices的3个顶点数据,我们后面从VBO取出的顶点位置数据将完全与之相等。
界面打印的第二类为VBO对象相关属性。这是我们的已知条件。我们将从该对象中提取调用getBufferSubData方法所需的各种参数数据。
当执行到代码:
WebGL引擎已将相应的数据存储在一个其内部管理的ArrayBuffer中,getBufferSubData的作用即从这个源缓冲区中提取相应的数据至一个指定的目标缓冲区中。
首先我们需要确定目标缓冲区的大小,以字节计量。下面代码实现此目的:
verticesVBO的compsSizes[0]代表每个顶点占用3个数组元素,verticesNum代表共有3个顶点,则算出elementsNum共有9个元素。我们使用Float32Array的类型化数组来打包为VBO,每个元素占用4个字节,则bufSize共有36个字节。我们以此创建了一个ArrayBuffer并赋值于变量arrayBuffer。
现在,可以调用getBufferSubData方法了:
参数srcByteOffset指定从源缓冲区哪个字节开始复制数据。上面代码以变量srcElementIndex指定从第0个数组元素开始复制,将其值乘以Float32Array的BYTES_PER_ELEMENT属性值,即得出要开始复制的字节偏移处,并赋值于参数srcByteOffset。
参数dstBuffer用于指定要存储数据的目标缓冲区,必须使用类型化数据的数据类型,这是因为上面的arrayBuffer是不能直接读写的。因此上面代码以arrayBuffer作为构造函数参数创建了Float32Array的一个实例。这样,WebGL引擎就可以通过该视图在arrayBuffer存储数据了。
参数dstOffset指定要将从源缓冲区中复制的数据,复制至目标缓冲区的第几个元素所在的位置。上面代码指定复制到目标缓冲区中第0个数组元素的位置。
参数length指定要从源缓冲区中共复制多少个数组元素。上面已算出elementsNum共有9个元素,我们需要全部复制,因此,将其值向参数length赋值即可。更多情况下,我们可以取用其值为0的默认值,则自动计算从dstOffset开始直至目标缓冲区最后一个元素之间的所有元素数量。
调用了getBufferSubData方法后,我们在界面上直接打印dstBuffer的值,并格式化打印该值。
从格式化打印结果来看,其与clientVertices的格式化打印结果完全一致。这也意味着,通过调用getBufferSubData方法,我们从VBO中精准地提取出了顶点位置数据。
需注意的是,参数srcByteOffset以字节为单位,而参数dstOffset及length不以字节为单位,后两者分别代表了数组元素索引值及数组元素数量。
WebGLBufferUtil的实现
WebGLBufferUtils类主要职责是从VBO中获取数组数据。包括了3个方法。
静态方法GetArrayBuffer用于从VBO中提取所存储的数组数据,返回ArrayBuffer的一个实例。
对于一个VBO来讲,需要注意的是它的attribNames属性值可能为['aPosition', 'aColor']
(复合VBO)或是其中的一种(简单VBO)。而本方法只取出参数attribName所指定的VBO。因此,它是从整体中取出一部分的关系。
gl的getBufferSubData方法每次只取出绑定至相应顶点属性的一个顶点的内容,然后,写入到arrBuffer以参数dstOffset所指定的位置中,然后更新dstOffset,直至处理完所有顶点。采用这种算法的原因是,在复合VBO中,绑定至特定顶点属性的所有数据在整个数组序列中是隔断排列的。最后,返回一个ArrayBuffer实例。
静态方法GetView允许使用任意类型化数组视图来操作VBO中的数组数据。
静态方法To2DArrays将VBO中一维的数组转化为二维的数组。
类型化数组有一个普通数组所没有的方法subarray,可用于方便地从类型化数组中截取一个子数组。在普通数组中可调用slice方法达到该目的。类型化数组与普通数组的方法差异,详见视图操控数据。
这3个静态方法是层层递进的关系,To2DArrays调用了GetView,后者又调用了GetArrayBuffer。而客户端在需要时又可以直接调用这三个方法。
CanvasUtils.js
CanvasUtils类是完成标注顶点位置信息的实际工作者。其代码如下:
首先,CanvasUtils类也采用了单例模式。
其次,addContainer方法用于构建DOM树。在应用程序运行后,其DOM树将如下所示:
- body
- div id="canvas-container"
- canvas
- div id="webgl-labels-container"
- span-1
- span-2
- span-3
- ...
- span-n
- div id="canvas-container"
第一,将id为webgl-labels-container
的div元素与canvas元素都置于id为canvas-container
的div元素之下,如前面所述,可避免标注顶点信息的元素在进入全屏模式时消失的情况。
第二,标注顶点信息时有一个比较棘手的重绘问题。当我们标注顶点信息时,如果用户改变了浏览器客户端,则需要重绘所有的span元素。
有一种思路是将重绘的代码放在render函数中,但这种方式将造成标注顶点信息这个辅助功能与WebGL应用的最主要函数紧紧地绑在了一起,代码越来越臃肿。
另一种思路是由CanvasUtils类独立处理resize事件。而这种方式又带来一个新的问题,即当我们同时标注两个以上VBO的顶点信息,在重绘时如何删除所有既存的标注信息,然后又让各个span元素依序独立重绘?
将所有span元素都置于id为webgl-labels-container
的标签之下,再调用本类的initWebEvents方法:
可解决一并删除已有的标注信息的问题。
而要解决让各个标注信息依序独立重绘的问题,可以先将这些span元素,且让它们都记住各自的二维数组、绘制选项等信息,都添加进一个数组,然后再通过遍历数组方式,让它们重绘。并且,在响应了重绘事件后,必须移除事件监听器,否则,事件监听器都越来越多而影响到程序效能。无疑,这样做将增加许多代码。
因此,markVerticesPos方法使用了两个技巧来避免出现这个问题,全部都体现在该方法的最后一句语句上:
一是使用JavaScript的闭包特性。上面代码,当标注完顶点位置信息后,注册新的resize事件监听器,在其内,再次调用该方法。由于调用该方法还是处于方法自身内,JavaScript的闭包功能允许我们在此访问方法的twoDimArray参数及opts参数。这个特性,直接省去了许多行代码。
二是使用了用完即弃的事件监听功能。在调用addEventListener方法时,最后一个参数{once: true}表示,在处理完事件后,立即卸载事件监听器,因此,这是一次性的消费。
具体来说,当客户端调用markVerticesPos方法后,该方法在处理完标注顶点信息工作后,先添加一个resize事件监听器,然后静静地等待事件的触发。而一旦resize事件被触发,JavaScript引擎先立即卸载此事件监听器,然后再重新调用markVerticesPos方法。在第二次的调用中,又重新添加事件监听器,然后又转入静静的等待状态。过程可谓完美。
markVerticesPos方法还使用了一个关于解包函数参数的技巧。
const {...} = opts ?? = {}表示,如果参数opts为空,则创建一个新的空对象以供解包;如果不为空,则直接解包。解包过程中,由于使用了解包默认值,因此能自动融汇客户端所传入的所有选项。
从功能上来讲,markVerticesPos方法先求出canavas基于CSS的实际大小及其原点位置,再将格式为
的参数twoDimArray映射到canavas坐标系上,最后通过CSS的绝对定位方法来定位各自的元素。
marking-vertices.html
有了上面3个类的支持,应用程序主体marking-vertices.html就相对比较简单了。
第一步,在网页结构上,添加新的父容器。
第二步,其JavaScript代码如下:
渲染了vao1及vao2,分别包装了简单VBO及复合VBO,以确保我们的应用适用于各种场合。
markVerticesPos函数专用于标注顶点位置信息。注意该函数在initVAOs函数而不是在render函数中被调用,这样不管是否需要标注顶点数据信息,都不会影响程序主干功能。
而在markVerticesPos函数中,我们通过两种方式来进行标注。一是通过调用glu的markVerticesPos方法,对两个VAOs进行标注。
参数{isShowCoords: true}可用于控制标注顶点时是否同时标注顶点坐标。可用的选项还有字体颜色、字体大小、Y轴上的间距等。
二是通过调用canvasUtils的markVerticesPos方法,对手工传入的二维数组进行标注。
同时支持这两种方式的调用,可满足不同场合的需求。
而在dispVBOInfo函数中,先从vao1中取出存储顶点位置数据的posVBO,调用WebGLBufferUtils的To2DArrays方法,直接提取其顶点位置数据后将其打印出来,以验证其与界面中显示的数据是否一致。
运行应用。随意改变浏览器大小,敲击回车键进入全屏模式,确保程序运行正常。
