标注顶点信息
撰写时间:2023-08-04
修订时间:2026-04-09
由于缺乏在canvas 中标注顶点的功能,我们很难看出各顶点在canvas 中的精准位置及名称等信息。
在本章中,我们实现一个很实用的功能:在canvas 中标注出各顶点序号及其位置信息。此功能可方便地帮助我们声明及修改各个图元的位置,从而能够较为轻松地渲染出精致而精准的图像。
实现思路
我们可以将span 标签以浮动的方式在canvas 上显示。
重点及难点在于两种渲染方式有不同的坐标系,因此需要将WebGL 的NDC 坐标系转换为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
每个VBO 及IBO 都属于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 ,接着调用BufUtils 的To2DArrays 静态方法,将其内部的数组数据转换为一个二维的数组。例如,将格式为
[
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]
];
在二维数组中,每个子元素都是一个顶点的坐标值数组,不仅直观,也很方便后续操作。
最后,将此二维数组继续传递给CanvasUtils 的markVerticesPos 方法,以在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
可以看出,VBO 是WebGLBuffer 的一个实例,而WebGLBuffer 的prototype 为Object ,除了我们自行添加的属性之外,WebGLBuffer 没有任何可以直接访问的属性。
好消息是,在WebGL 2.0 中,我们可以调用gl 的getBufferSubData 方法,直接从GPU 内存中读取一个VBO 的信息。
getBufferSubData原型
getBufferSubData 方法的原型如下:
undefined getBufferSubData
GLenum target
GLintptr srcByteOffset
[AllowShared] ArrayBufferView dstBuffer
unsigned long long [dstOffset = 0]
GLuint [length = 0]
target
指定所绑定的目标。可为gl .ARRAY_BUFFER 或gl .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 。因此,它是从整体中取出一部分的关系。
gl 的getBufferSubData 方法每次只取出绑定至相应顶点属性的这一部分中的一个顶点的内容,然后,写入到arrBuffer 以参数dstOffset 所指定的位置中。最后,返回一个ArrayBuffer 实例。
静态方法GetView 允许使用任意类型化数组视图来操作VBO 中的数组数据。
static GetView(vbo, attribName, typedArray) {
let arrBuffer = WebGLBufferUtils.GetArrayBuffer(vbo, attribName);
return new typedArray(arrBuffer);
}
静态方法To2DArrays 将VBO 中一维的数组转化为二维的数组。
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
第一,将id 为webgl-labels-container 的div 元素与canvas 元素都置于id 为canvas-container 的div 元素之下,如前面所述,可避免标注顶点信息的元素在进入全屏模式时消失的情况。
第二,标注顶点信息时有一个比较棘手的重绘问题。当我们标注顶点信息时,如果用户改变了浏览器客户端,则需要重绘所有的span 元素。
有一种思路是将重绘的代码放在render 函数中,但这种方式将造成标注顶点信息这个辅助功能与WebGL 应用的最主要函数紧紧地绑在了一起,代码越来越臃肿。
另一种思路是由CanvasUtils 类独立处理resize 事件。而这种方式又带来一个新的问题,即当我们同时标注两个以上VBO 的顶点信息,在重绘时如何删除所有既存的标注信息,然后又让各个span 元素依序独立重绘?
将所有span 元素都置于id 为webgl-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);
}
渲染了vao1 及vao2 ,分别包装了简单VBO 及复合VBO ,以确保我们的应用适用于各种场合。
markVerticesPos 函数专用于标注顶点位置信息。注意该函数在initVAOs 函数而不是在render 函数中被调用,这样不管是否需要标注顶点数据信息,都不会影响程序主干功能。
而在markVerticesPos 函数中,我们通过两种方式来进行标注。一是通过调用glu 的markVerticesPos 方法,对两个VAOs 进行标注。
glu.markVerticesPos(vao1, {isShowCoords: true});
glu.markVerticesPos(vao2);
参数{isShowCoords: true} 可用于控制标注顶点时是否同时标注顶点坐标。可用的选项还有字体颜色、字体大小、Y轴上的间距等。
二是通过调用canvasUtils 的markVerticesPos 方法,对手工传入的二维数组进行标注。
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});
同时支持这两种方式的调用,可满足不同场合的需求。
运行应用 。随意改变浏览器大小,敲击回车键进入全屏模式,确保程序运行正常。