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

标注顶点信息

撰写时间:2023-08-04

修订时间:2026-04-09

由于缺乏在canvas中标注顶点的功能,我们很难看出各顶点在canvas中的精准位置及名称等信息。

在本章中,我们实现一个很实用的功能:在canvas中标注出各顶点序号及其位置信息。此功能可方便地帮助我们声明及修改各个图元的位置,从而能够较为轻松地渲染出精致而精准的图像。

实现思路

我们可以将span标签以浮动的方式在canvas上显示。

重点及难点在于两种渲染方式有不同的坐标系,因此需要将WebGLNDC坐标系转换为Canvas 2D坐标系。

此外,我们可以针对VAO进行操作,取出其顶点位置的VBOs,再将这些VBOs的原始顶点位置数据提取出来。

因涉及多个类之间的相互配合,本章另一个核心内容就是通过应用设计模式来完善类与类之间交互关系,尽量减少类与类之间的依赖与耦合。

WebGLUtils-v7.js

WebGLUtils-v7.js这次改动的地方较多,共有5个方面,但均是小改动。

添加一个GLUHolder类

从现在开始,除了WebGLUtils类,以及之前使用过的GLColors类,我们将根据应用程序所需,逐渐开发各个相应的类,将类的职责分开,让它们各司其职,只完成自己份内应做的工作,从而实现应用程序的模块化设计,使应用程序更加强壮、稳健,也更易于维护。

第一,先导入两个辅助工具类。

import {default as BufUtils} from './WebGLBufferUtils.js'; import {default as CanvasUtils} from './CanvasUtils.js';

这两个类下面再详细论述。

第二,添加一个GLUHolder类。

export class GLUHolder { static #glu = null; static set(value) { if (!value || !(value instanceof WebGLUtils)) { throw new Error("Error! instance is not an instance of WebGLUtils!"); } GLUHolder.#glu = value; } } export class WebGLUtils { constructor({ canvasId = 'webgl-canvas', shaderIds = ['vShader', 'fShader'], vertexAttribs, renderFunc }){ this.canvas = null; this.gl = null; this.program = null; this.renderFunc = renderFunc; this.initWebEvents(); this.getContext(canvasId); this.initProgram(shaderIds, vertexAttribs); GLUHolder.glu = this; } ... }

从前面各个例子中看到,glu是用得较多的一个变量。而各个新开发的类,有时也会不可避免地会使用到该变量。因此,我们新建了GLUHolder类,该类通过静态属性的方式,向外提供访问glu的机会。在需要引用其的各个类中,只需使用下面代码即可:

let glu = GLUHolder.glu;

而完成赋值工作的代码放在WebGLUtils类的构造方法的最后一行:

GLUHolder.glu = this;

这样,在创建WebGLUtils类的一个实例后,将该实例的引用通过GLUHolder的静态set方法自动完成赋值工作。

此外,删除WebGLUtils-v7.js最后一行:

export default WebGLUtils;

上面的代码显示出,我们已经改为分别导出GLUHolder类及WebGLUtils类。

修改进入全屏状态的元素

之前,运行应用后,当我们输入回车键时,我们设置为canvas标签将进入全屏状态。这在之前没有任何问题,因为它是当时网页的body中的唯一一个子元素。

但由于我们要借助于自动创建各个span标签来完成顶点位置的标注工作,按之前的设置,在canvas进行全屏后,其他元素将自动隐藏,导致全屏状态下的标注信息消失。解决方法是为canvas元素添加一个父节点,然后将新创建的众多span元素,也归置于此父节点下,然后让此父节点进行全屏状态。这里相应的代码修改如下:

initWebEvents() { window.addEventListener('resize', () => { this.updateViewport(); this.renderFunc(); }); document.addEventListener("keydown", evt => { if (evt.code === 'Enter') { if (!document.fullscreenElement) { document.querySelector('#canvas-container').requestFullscreen(); } else { document.exitFullscreen(); } } }); }

记住bindingTarget

每个VBOIBO都属于WebGLBuffer类,在使用时都需要绑定至特定的目标,我们要创建的新类可能要针对WebGLBuffer统一操作,因此需辨别它们相应的绑定目标。我们将它们自己的绑定目标存储于各自的bindingTarget属性中。

createVBO(arrData, compsSizes, attribsNames) { let gl = this.gl; let vbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arrData), gl.STATIC_DRAW); vbo.bindingTarget = gl.ARRAY_BUFFER; ... } createIBO(vao, indices) { const { gl } = this; let ibo = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW); ibo.bindingTarget = gl.ELEMENT_ARRAY_BUFFER; ... }

让VAO持有各个VBOs

在创建VAO时,我们传入多个VBOs,这样WebGL就能记住各个VBOs的状态。但我们尚不能从VAO中方便地提取各个VBO,因此,我们可以通过为VAO添加一个vbos属性,让其记住所有相关联的VBOs

修改createVAO方法如下:

createVAO(...vbos) { const { gl } = this; let vao = gl.createVertexArray(); vao.vbos = []; gl.bindVertexArray(vao); for (let vbo of vbos) { this.bindVboToAttribs(vbo); vao.vbos.push(vbo); } gl.bindVertexArray(null); vao.verticesNum = vbos[0].verticesNum; return vao; }

这样,VAO就持有了相关联的VBOs的所有实例。

标注顶点位置信息

WebGLUitls类的末尾添加markVerticesPos方法。

markVerticesPos(vao, opts) { let verticesVBO = vao.vbos.find((vbo) => vbo.attribsNames.includes('aPosition')); let twoDimArr = BufUtils.To2DArrays(verticesVBO, 'aPosition', Float32Array); let canvasUtils = CanvasUtils.instance; canvasUtils.markVerticesPos(twoDimArr, opts); }

该方法是本章内容的核心部分。首先,根据传入的vao参数,找出其与aPosition绑定的verticesVBO,接着调用BufUtilsTo2DArrays静态方法,将其内部的数组数据转换为一个二维的数组。例如,将格式为

[ 0.0, 0.5, 0.0, -0.5, -0.5, 0.0, 0.5, -0.5, 0.0 ];

这样的一维数组,转换为以下格式的二维数组:

[ [ 0.0, 0.5, 0.0], [-0.5, -0.5, 0.0], [ 0.5, -0.5, 0.0] ];

在二维数组中,每个子元素都是一个顶点的坐标值数组,不仅直观,也很方便后续操作。

最后,将此二维数组继续传递给CanvasUtilsmarkVerticesPos方法,以在Canavs中将这些数据在相应位置标注出来。

注意到CanvasUtils采用了设计模式中的单例模式,通过调用其静态属性instance返回单例,这样,不仅方便调用,也确保了多个调用之间均访问同一实例。

同时,WebGLUtils类的markVerticesPos方法调用了CanvasUtils的同名方法,这是设计模式中门面模式的典型应用。这样设计,主要基于两点考虑,一是隐藏实现细节,二是增加多样性。如果需要对VAO操作,则调用WebGLUtils类的该方法;如果针对通用的二维数组操作,则调用CanvasUtils的该方法。在本章最终的例子中,将看到这两个方法均得到了调用。

此外,对比WebGLBufferUtils类与CanvasUtils类,前者的各个方法没有使用到需保存为自身状态的变量,因此各个方法以静态方法实现;而后者用到了保存为自身状态的变量,则必须先经初始化,但与其让客户端随意地调用其构造函数来创建各个实例,它以单例模式提供了一致的调用接口及统一的状态。

上面的代码看似很简单,但实际上在后台做了大量的工作,只不过是将这些工作分别交付给WebGLBufferUtils类及CanvasUtils类来完成。这也是设计模式中代理模式的体现。

从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的信息。

WebGLBuffer Info
WebGLBuffer Info

可以看出,VBOWebGLBuffer的一个实例,而WebGLBufferprototypeObject,除了我们自行添加的属性之外,WebGLBuffer没有任何可以直接访问的属性。

好消息是,在WebGL 2.0中,我们可以调用glgetBufferSubData方法,直接从GPU内存中读取一个VBO的信息。

getBufferSubData原型

getBufferSubData方法的原型如下:

undefinedgetBufferSubData
  • GLenumtarget
  • GLintptrsrcByteOffset
  • [AllowShared] ArrayBufferViewdstBuffer
  • unsigned long long[dstOffset = 0]
  • GLuint[length = 0]
target
指定所绑定的目标。可为gl.ARRAY_BUFFERgl.ELEMENT_ARRAY_BUFFER
srcByteOffset
指定源缓冲区的开始偏移值,以字节为单位。
dstBuffer
指定存放数据的目标缓冲区,类型为类型化数组。
[dstOffset = 0]
目标缓冲区的开始偏移值。可选。默认值为0
[length = 0]
要读取的长度。可选。默认值为0

WebGLBufferUtil的实现

WebGLBufferUtils类主要职责是从VBO中获取数组数据。包括了3个方法。

import {GLUHolder} from './WebGLUtils-v7.js'; const FB_SIZE = Float32Array.BYTES_PER_ELEMENT; export default class WebGLBufferUtils { static GetArrayBuffer(vbo, attribName) {...} static GetView(vbo, attribName, typedArray) {...} static To2DArrays(vbo, attribName, typedArray) {...} }

静态方法GetArrayBuffer用于从VBO中提取所存储的数组数据,返回ArrayBuffer的一个实例。

static GetArrayBuffer(vbo, attribName) { if (!vbo.attribsNames.includes(attribName)) { return null; } let attribNameIndex = vbo.attribsNames.indexOf(attribName); let attribNameStartOffset = 0; { let pointer = 0; while (pointer < attribNameIndex) { let compSize = vbo.compsSizes[pointer]; attribNameStartOffset += compSize; pointer ++; } } let attribNameCompSize = vbo.compsSizes[attribNameIndex]; let attribNameBufSize = FB_SIZE * attribNameCompSize * vbo.verticesNum; let gl = GLUHolder.glu.gl; let bindingTarget = vbo.bindingTarget; gl.bindBuffer(bindingTarget, vbo); let dstOffset = 0; const arrBuffer = new ArrayBuffer(attribNameBufSize); for (let index = 0; index < vbo.verticesNum; index++) { gl.getBufferSubData(bindingTarget, FB_SIZE * attribNameStartOffset, new Float32Array(arrBuffer), dstOffset, attribNameCompSize); if (vbo.strideSize === 0) { attribNameStartOffset += attribNameCompSize; } else { attribNameStartOffset += vbo.strideSize; } dstOffset += attribNameCompSize; } return arrBuffer; }

对于一个VBO来讲,需要注意的是它的attribNames属性值可能为['aPosition', 'aColor']或是其中的一种。而本方法只取出参数attribName所指定的VBO。因此,它是从整体中取出一部分的关系。

glgetBufferSubData方法每次只取出绑定至相应顶点属性的这一部分中的一个顶点的内容,然后,写入到arrBuffer以参数dstOffset所指定的位置中。最后,返回一个ArrayBuffer实例。

静态方法GetView允许使用任意类型化数组视图来操作VBO中的数组数据。

static GetView(vbo, attribName, typedArray) { let arrBuffer = WebGLBufferUtils.GetArrayBuffer(vbo, attribName); return new typedArray(arrBuffer); }

静态方法To2DArraysVBO中一维的数组转化为二维的数组。

static To2DArrays(vbo, attribName, typedArray) { let view = WebGLBufferUtils.GetView(vbo, attribName, typedArray); let result = []; let compSize = view.length / vbo.verticesNum; for (let i = 0; i < view.length; i += compSize) { result.push(view.subarray(i, i + compSize)); } return result; }

这3个静态方法是层层递进的关系,To2DArrays调用了GetView,后者又调用了GetArrayBuffer。而客户端在需要时又可以直接调用这三个方法。

CanvasUtils.js

CanvasUtils类是完成标注顶点位置信息的实际工作者。其代码如下:

export default class CanvasUtils { static #instance = null; static get instance() { if (!CanvasUtils.#instance) { CanvasUtils.#instance = new this(); } return CanvasUtils.#instance; } constructor(canvasId = "webgl-canvas") { this.canvas = document.querySelector(`#${canvasId}`); this.addContainer(); this.initWebEvents(); } addContainer() { this.labelsContainer = document.createElement('div'); this.labelsContainer.id = 'webgl-labels-container'; document.querySelector('#canvas-container').appendChild(this.labelsContainer); } initWebEvents() { window.addEventListener('resize', evt => { this.labelsContainer.innerHTML = ''; }); } markVerticesPos(twoDimArray, opts) { const { fontColor = 'white', fontSize = '1em', yMargin = 10, isShowCoords = false } = opts ??= {}; let canvas = this.canvas; let org = { x: canvas.clientWidth / 2, y: canvas.clientHeight / 2 }; let minValue = Math.min(canvas.clientWidth, canvas.clientHeight); let bufferWidth = minValue; let bufferHeight = minValue; let posIndex = 0; for (let typedArray of twoDimArray) { let labelSpan = document.createElement('span'); labelSpan.className = 'webgl-label'; labelSpan.style.position = 'absolute'; labelSpan.style.fontFamily = '-apple-system,BlinkMacSystemFont,Helvetica'; labelSpan.style.color = fontColor; labelSpan.style.fontSize = fontSize; labelSpan.textContent = `V${posIndex}`; if (isShowCoords) { let formattedText = this.formatCoordsText(typedArray.toLocaleString()); labelSpan.textContent += ` ${formattedText}`; } this.labelsContainer.appendChild(labelSpan); let x = typedArray[0]; let y = typedArray[1]; let xPos = org.x - labelSpan.clientWidth / 2 + x * bufferWidth / 2; let yPos = org.y - labelSpan.clientHeight / 2 - y * bufferHeight / 2; if (y <= 0) { yPos = yPos + labelSpan.clientHeight / 2 + yMargin; } else { yPos = yPos - (labelSpan.clientHeight / 2 + yMargin); } labelSpan.style.left = `${xPos}px`; labelSpan.style.top = `${yPos}px`; posIndex++; } window.addEventListener('resize', evt => { this.markVerticesPos(twoDimArray, opts); }, {once: true}); } formatCoordsText(text) { let map = text.split(',').map(value => { if (value === '0') { value = '0.0'; } return value; }); return '(' + map.join(', ') + ')'; }; }

首先,CanvasUtils类也采用了单例模式

其次,addContainer方法用于构建DOM树。在应用程序运行后,其DOM树将如下所示:

  • body
    • div id="canvas-container"
      • canvas
      • div id="webgl-labels-container"
        • span-1
        • span-2
        • span-3
        • ...
        • span-n

第一,将idwebgl-labels-containerdiv元素与canvas元素都置于idcanvas-containerdiv元素之下,如前面所述,可避免标注顶点信息的元素在进入全屏模式时消失的情况。

第二,标注顶点信息时有一个比较棘手的重绘问题。当我们标注顶点信息时,如果用户改变了浏览器客户端,则需要重绘所有的span元素。

有一种思路是将重绘的代码放在render函数中,但这种方式将造成标注顶点信息这个辅助功能与WebGL应用的最主要函数紧紧地绑在了一起,代码越来越臃肿。

另一种思路是由CanvasUtils类独立处理resize事件。而这种方式又带来一个新的问题,即当我们同时标注两个以上VBO的顶点信息,在重绘时如何删除所有既存的标注信息,然后又让各个span元素依序独立重绘?

将所有span元素都置于idwebgl-labels-container的标签之下,再调用本类的initWebEvents方法:

initWebEvents() { window.addEventListener('resize', evt => { this.labelsContainer.innerHTML = ''; }); }

可解决一并删除已有的标注信息的问题。

而要解决让各个标注信息依序独立重绘的问题,可以先将这些span元素,且让它们都记住各自的二维数组、绘制选项等信息,都添加进一个数组,然后再通过遍历数组方式,让它们重绘。并且,在响应了重绘事件后,必须移除事件监听器,否则,事件监听器都越来越多而影响到程序效能。无疑,这样做将增加许多代码。

因此,markVerticesPos方法使用了两个技巧来避免出现这个问题,全部都体现在该方法的最后一句语句上:

markVerticesPos(twoDimArray, opts) { ... window.addEventListener('resize', evt => { this.markVerticesPos(twoDimArray, opts); }, {once: true}); }

一是使用JavaScript的闭包特性。上面代码,当标注完顶点位置信息后,注册新的resize事件监听器,在其内,再次调用该方法。由于调用该方法还是处于方法自身内,JavaScript的闭包功能允许我们在此访问方法的twoDimArray参数及opts参数。这个特性,直接省去了许多行代码。

二是使用了用完即弃的事件监听功能。在调用addEventListener方法时,最后一个参数{once: true}表示,在处理完事件后,立即卸载事件监听器,因此,这是一次性的消费。

具体来说,当客户端调用markVerticesPos方法后,该方法在处理完标注顶点信息工作后,先添加一个resize事件监听器,然后静静地等待事件的触发。而一旦resize事件被触发,JavaScript引擎先立即卸载此事件监听器,然后再重新调用markVerticesPos方法。在第二次的调用中,又重新添加事件监听器,然后又转入静静的等待状态。过程可谓完美。

markVerticesPos方法还使用了一个关于解包函数参数的技巧。

markVerticesPos(twoDimArray, opts) { const { fontColor = 'white', fontSize = '1em', yMargin = 10, isShowCoords = false } = opts ??= {}; ... }

const {...} = opts ?? = {}表示,如果参数opts为空,则创建一个新的空对象以供解包;如果不为空,则直接解包。解包过程中,由于使用了解包默认值,因此能自动融汇客户端所传入的所有选项。

从功能上来讲,markVerticesPos方法先求出canavas的实际大小及其原点位置,再将格式为

[ [ 0.0, 0.5, 0.0], [-0.5, -0.5, 0.0], [ 0.5, -0.5, 0.0] ];

的参数twoDimArray映射到canavas坐标系上,最后通过CSS的绝对定位方法来定位各自的元素。

FullScreenCanvas.css

修改FullScreenCanvas.css文件的内容如下:

html, body { margin: 0; height: 100%; } #canvas-container { width: 100%; height: 100%; } canvas { display: block; width: 100%; height: 100%; }

canvas-container现在是最外层的容器,让其铺满浏览器客户端的全部空间,以确保全屏功能的正常应用。其原理在上面相关章节已经谈过。

marking-vertices.html

有了上面3个类的支持,应用程序主体marking-vertices.html就相对比较简单了。

第一步,在网页结构上,添加新的父容器。

Your browser does not support canvas!

第二步,其JavaScript部分的代码如下:

import {WebGLUtils} from './js/esm/WebGLUtils-v7.js'; import {default as CanvasUtils} from './js/esm/CanvasUtils.js'; import defaultExport from '/js/esm/ArrayExtensions.js'; import { GLColors } from '/js/esm/GLColors.js'; let glu, gl; let vao1, vao2; let canvasUtils = CanvasUtils.instance; init(); function init() { initContext(); initVAOs(); render(); } function initContext() { glu = new WebGLUtils({ vertexAttribs: ['aPosition', 'aColor'], renderFunc: render }); gl = glu.gl; gl.clearColor(0, 0, 0, 1); } function initVAOs() { let verticesVBO = glu.createPosVBO([ -0.50, 0.4, 0.0, -0.85, -0.4, 0.0, -0.15, -0.4, 0.0 ]); let colorsVBO = glu.createColorVBO(GLColors.GetRandomSofterRGB().repeat(verticesVBO.verticesNum)); vao1 = glu.createVAO(verticesVBO, colorsVBO); let cpVBO = glu.createPosColorVBO([ 0.15, 0.4, 0.0, 0.0, 0.0, 1.0, 1.0, 0.50, -0.4, 0.0, 1.0, 0.0, 0.0, 1.0, 0.85, 0.4, 0.0, 0.0, 1.0, 0.0, 1.0 ]); vao2 = glu.createVAO(cpVBO); glu.createIBO(vao2, [ 0, 1, 2 ]); markVerticesPos(); } function markVerticesPos() { glu.markVerticesPos(vao1, {isShowCoords: true}); glu.markVerticesPos(vao2); canvasUtils.markVerticesPos([ [ 0.0, 0.0, 0.0], [-0.9, 0.9, 0.0], [-0.9, -0.9, 0.0], [ 0.9, -0.9, 0.0], [ 0.9, 0.9, 0.0] ], {isShowCoords: false}); } function render() { gl.clear(gl.COLOR_BUFFER_BIT); glu.renderVAO(vao1); glu.renderVAO(vao2); }

渲染了vao1vao2,分别包装了简单VBO及复合VBO,以确保我们的应用适用于各种场合。

markVerticesPos函数专用于标注顶点位置信息。注意该函数在initVAOs函数而不是在render函数中被调用,这样不管是否需要标注顶点数据信息,都不会影响程序主干功能。

而在markVerticesPos函数中,我们通过两种方式来进行标注。一是通过调用glumarkVerticesPos方法,对两个VAOs进行标注。

glu.markVerticesPos(vao1, {isShowCoords: true}); glu.markVerticesPos(vao2);

参数{isShowCoords: true}可用于控制标注顶点时是否同时标注顶点坐标。可用的选项还有字体颜色、字体大小、Y轴上的间距等。

二是通过调用canvasUtilsmarkVerticesPos方法,对手工传入的二维数组进行标注。

canvasUtils.markVerticesPos([ [ 0.0, 0.0, 0.0], [-0.9, 0.9, 0.0], [-0.9, -0.9, 0.0], [ 0.9, -0.9, 0.0], [ 0.9, 0.9, 0.0] ], {isShowCoords: false});

同时支持这两种方式的调用,可满足不同场合的需求。

运行应用。随意改变浏览器大小,敲击回车键进入全屏模式,确保程序运行正常。

参考资源

  1. WebGL 2.0 Specification