多视口
撰写时间:2023-08-20
修订时间:2026-04-24
所谓多视口,是指在WebGL应用中,允许我们将整个canvas划分为不同的区域,不同的区域可以使用各自的投影变换、视图变换。正如我们在3D Max或Blender等应用程序中可以同时使用自定义投影视图或顶视图来编辑同一物体。
本章中,我们将实现如下多视口应用。

在左边视口中,相机视域为30,在网格物体的右上方望向网格物体。在右边视口中,相机视域为60,在网格物体的上方望向网格物体。
而要实现这一个很酷的功能,关键是需深入理解视口及投影变换、视图变换、模型变换的关系。
视口相关因素
每有一个视口,则需有以下元素支撑:
- canvas。在此元素上决定划分为多少个视口。每当canvas尺寸发生改变,则需在resize事件中改变视口大小及投影矩阵。
- 投影矩阵。每个视口允许拥有各自的投影矩阵。
- 视图矩阵。每个视口允许拥有各自的视图矩阵。这样可在不同的视口使用不同的相机。
- 模型矩阵。对于所有的视口来讲,模型矩阵是共享的。这意味着我们可以同时在顶视图及左视图中编辑或渲染同一网格物体。
鉴于每个视口需持有上述的对象的内部状态,显然,我们应将其设置为一个新类。并且,它将根据这些内部状态来决定如何渲染,也即其应该有自己的render方法。
我们之前的应用程序都是在主干流程中进行渲染,而在多视口应用中,却改为由各个视口来自行渲染,我们的应用程序将变为基于视口驱动的应用程序。
重构WebGLUtils类
为简化客户端代码,之前的WebGLUtils-v9.js紧紧绑定了只使用一个视口的功能:
而在多视口应用中,使用多少个视口、如何安排多个视口,均应由客户端决定。因此上面的绑定功能应予删除。
故此将其重构为WebGLUtils-v10.js,其内容如下:
const FB_SIZE = Float32Array.BYTES_PER_ELEMENT;
const U16B_SIZE = Uint16Array.BYTES_PER_ELEMENT;
import { Mesh } from './index-independent.js';
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'],
uniforms,
vertexAttribs
}){
this.canvas = null;
this.gl = null;
this.program = null;
this.getContext(canvasId);
this.initProgram(shaderIds, vertexAttribs, uniforms);
GLUHolder.glu = this;
Mesh.setGLUHolder(GLUHolder);
}
getContext(canvasId) {
if (!window.WebGL2RenderingContext) {
alert("Error! Window does not support WebGL2RenderingContext!");
}
let canvas = document.getElementById(canvasId);
if (!canvas) {
alert(`Error! Can not get canvas for ${canvasId}!`);
}
let context = canvas.getContext("webgl2");
if (!context) {
alert("Error! Can not get context for webgl!");
}
this.canvas = canvas;
this.gl = context;
}
initProgram([vShaderId, fShaderId], vertexAttribs, uniforms) {
let gl = this.gl;
const vertexShader = loadShader(vShaderId);
const fragmentShader = loadShader(fShaderId);
let 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);
this.program = program;
uniforms.forEach((uniformName) => {
let loc = this.gl.getUniformLocation(this.program, uniformName);
this.program[uniformName] = loc;
});
vertexAttribs.forEach((attribName) => {
let loc = this.gl.getAttribLocation(this.program, attribName);
this.program[attribName] = loc;
});
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;
}
}
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;
vbo.strideSize = 0;
if (Array.isArray(compsSizes)) {
compsSizes.forEach(value => vbo.strideSize += value);
vbo.verticesNum = arrData.length / vbo.strideSize;
vbo.compsSizes = compsSizes;
} else {
vbo.verticesNum = arrData.length / compsSizes;
vbo.compsSizes = Array.of(compsSizes);
}
if (Array.isArray(attribsNames)) {
vbo.attribsNames = attribsNames;
} else {
vbo.attribsNames = Array.of(attribsNames);
}
return vbo;
}
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;
ibo.indicesNum = indices.length;
vao.ibo = ibo;
}
createPosVBO(arrData, compSize = 3) {
return this.createVBO(arrData, compSize, 'aPosition');
}
createColorVBO(arrData, compSize = 4) {
return this.createVBO(arrData, compSize, 'aColor');
}
createPosColorVBO(arrData, compsSizes = [3, 4]) {
return this.createVBO(arrData, compsSizes, ['aPosition', 'aColor']);
}
bindVboToAttribs(vbo) {
let gl = this.gl;
let program = this.program;
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
let index = 0;
for (let attribName of vbo.attribsNames) {
gl.vertexAttribPointer(program[attribName], vbo.compsSizes[index], gl.FLOAT, false, FB_SIZE * vbo.strideSize, FB_SIZE * getOffsetFromCompsSizes(vbo.compsSizes, index));
gl.enableVertexAttribArray(program[attribName]);
index++;
}
function getOffsetFromCompsSizes(compsSizes, index) {
let sum = 0;
let currIndex = 0;
while (currIndex < index) {
sum += compsSizes[currIndex];
currIndex++;
}
return sum;
}
}
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;
}
renderVAO(vao, isWireframe = false) {
const { gl } = this;
let renderType = isWireframe ? gl.LINES : gl.TRIANGLES;
gl.bindVertexArray(vao);
if (vao.ibo) {
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vao.ibo);
gl.drawElements(renderType, vao.ibo.indicesNum, gl.UNSIGNED_SHORT, U16B_SIZE * 0);
} else {
gl.drawArrays(renderType, 0, vao.verticesNum);
}
}
}
主要是做减法。我们将设置视口的方法、事件处理器全部从该类中删除,转移至另一个新类中。
因为将由各个视口来自行决定如何渲染,因此其原来相关的渲染职责也被剔除了。
这样重构后,WebGLUtils类的职责更加专一、明确。
客户端代码
主干流程代码
主干流程中没有了render函数,而是代之以最后一行的resize事件触发机制。
initContext
无论是WebGLUtils,还是主干流程,均没有render函数,因此WebGLUtils的构造器中也无需再传入其函数指针。
initWebEvents
改由在主干流程中实现切换全屏的功能。
initScene
上面谈到,所有视口中所要渲染的网格物体均为同一共享的主体,因此,将所有的网格物体均打包进一个Scene类中,到需要时由其负责代为负责渲染所有网格物体。实现起来很简单,代码如下:
采用单例模式,addMesh将网格物体添加进内部数组,renderMeshes负责渲染所有网格物体。
initViewports
引入新类Viewport,在构建其实例过程中,传入其所需的canvas、视域、视图矩阵。最后的参数viewportOpts以对象的方式,封装了视口布局为左右布局、且此视口为该布局中的第1个视口的需求。
注意这里只需创建Viewport的一个实例,主干流程无需持有实例引用,也无需调用其负责渲染的方法。这是因为在其内部有自动响应resize事件的机制。
基于事件的渲染机制
主干流程的最后一行:
手动触发一个resize事件,上面所创建的Viewport实例收到后,将自动响应该事件,以完成渲染功能。
为何手动触发resize事件?
在应用程序运行后,只有当我们拉动浏览器的边框,才会触发resize事件。我们需要在事件响应代码中相应地调整各种内部状态后再次进行渲染。这是程序流程的一条岔道。
而当应用程序一开始运行时,此时我们还未来得及拉动浏览器的边框以触发resize事件,则我们需要通过程序流程的另一条岔道来初始化各种内部状态并直接渲染。
两条岔道,意味着代码逻辑出现了重叠部分。如果以后需要修改代码,我们得时刻记得同时修改两个地方的代码,很容易出错。
如果程序一开始运行时,我们就手动触发resize事件,则我们只需维护一条岔道的代码,就可以确保应用程序开始运行及用户拉动浏览器边框时,均导致同样的效果。
现在,应用构架已经搭建好,所剩下的,就是看Viewport类如何响应resize事件了。
Viewport类
构造器
在构造器中,将参数中所传入的canvas, fov,viewMatrix及viewportOpts均保存为实例变量,以供自身的其他方法方便调用。
本应用程序中,我们将仅使用透视投影,不使用正交投影,因此对于各个视口,我们将根据所传入的参数fov来创建该视口自身的投影矩阵,实例变量projectionMatrix正为此目的而存在。在上面构造器代码中,直接为将其初始始化为单位矩阵。
构造方法中,调用了initCSSBorderDOM方法来初始化一个div元素,用以标识自身视口的边框:
上面便完成了所有内部实例变量的初始化。剩下的就是Viewport类的核心代码:事件响应机制了。
initWebEvents
当收到resize事件触发消息时,在onResize方法中统一予以响应。
onResize
对于Viewport类来讲,其最核心的组件就是一个代表视口四至范围的矩形。我们使用实例变量rect来存储其数据。而它与其他组件有紧密的相互依赖联系。
rect依赖于帧缓冲区的尺寸,因此,canvas大小发生改变时,syncDrawingBuffer先据此同步帧缓冲区的尺寸,然后再调用updateViewportRect更新rect的尺寸。
而后续的updateProjectionMatrix、updateViewport及updateCSSBorderDOM方法均依赖于rect的实际尺寸,故此随后才被调用。
最后,调用render方法,实现最终渲染。
syncDrawingBuffer
尽管从代码上看不出来,但我们之前谈到,当canvas的width及height属性值发生改变时,将自动改变gl的drawingBufferWidth及drawingBufferHeight这两个只读的属性值。
updateViewportRect
之前客户端所传进的viewportOpts的值为:
这里通过引入一个新类ViewportLayoutManager,其静态方法GetViewport将根据所传入的参数来获取一个代表矩形的对象:
其代码目前实现了LEFT_RIGHT(左右布局)及TOP_BOTTOM(上下布局),且这两种布局均仅有2个视口。
这里需要特别注意的是,我们日常接触的表示一个矩形的对象,绝大部分均是以left, top, width, height属性来标识一个矩形。
可见当我们上面为x及y赋值时,实际上就是为left及top赋值。
但gl的viewport方法的原型中:
参数x及y分别用于指定矩形的左下角,因此其原型实际应为:
鉴于此,ViewportLayoutManager的静态方法GetViewport的返回值使用对象用以明确标识。例如,对于左右布局的左视口,所返回的对象为:
对于左右布局的右视口,所返回的对象为:
依此类推。
这里通过引入一个新类ViewportLayoutManager用于专门负责生成各种视口的矩形的意义在于,为以后应用程序的扩展留出了充分的空间。
例如,左右布局除了上述左右各有1个视口之外,还有左1右2、左1右3、左2右2,如此等等。当以后有此需求时,直接修改ViewportLayoutManager即可。而添加这些功能,不过是掰着手指头算数的问题,非常简单。
确定了实例变量rect的值后,后续3个依赖于它的方法就非常简单了。
updateViewport
将updateViewport列于updateProjectionMatrix方法之后的意义在于,当在帧缓冲区渲染完毕后,顶点坐标系为裁剪坐标系,之后WebGL渲染管道才会调用gl的viewport方法,完成从裁剪坐标系到世界坐标系再到canvas坐标系的矩阵变换。
updateCSSBorderDOM
CSS属性值与devicePixelRatio值无关,因此需将位于帧缓冲区中的各个值分别除以devicePixelRatio。具体原因参见isPointInPath的小问题。
render
每个Viewport实例都持有自己的投影矩阵及视图矩阵,因此在渲染前先将它们传输到顶点着色器中。
而由于模型矩阵是共享的,因此委托Scene的实例予以渲染。
运行single-viewport.html应用程序。
这是一个在左右视口布局中的单视口的应用程序,渲染结果出现在左边视口中。
为何代码变化如此之大
与前面章节的代码相比,为何本章中的代码变化如此之大?
我们之前仅使用一个视口,且自动铺满canvas的全部区域。此时,投影矩阵、视图矩阵及模型矩阵均仅各为1个,我们无需考虑过多细节。因此,我们自然而然地将实现相关的视口的功能均塞进了WebGLUtils类中。
但一旦需要实现多视口功能,上述细节就暴露出各种问题。由于缺失专属的Viewport类,WebGLUtils被迫代劳了许多它不应干的活。
因此,这一章不仅从WebGLUtils中剥离了相应的职责,且新增了Viewport、ViewportManager等类。
同时通过本章视口相关因素一节的分析,我们厘清了Viewport与投影矩阵、视图矩阵及模型矩阵各不相同的复杂关系。并且,为体现网格物体可被共享的价值,引入了Scene类,由其代为在每个不同的视口中渲染同样的网格物体。
并且,我们在这里第一次提出并分析了基于事件的渲染机制,一并解决了之前并未注意、但又确实存在的关键问题。
甚至,当我们将Viewport的updateProjectionMatrix及updateViewport方法并排在一起的时候,我们才开始意识到这两个方法的先后顺序,正对应了WebGL渲染管道对不同坐标系的矩阵变换顺序。
以上这些,均为这一次重构过程中所考虑到并已妥当解决的问题。
其结果是,通过正确地引入各个新类,我们第一次构建出了一个WebGL应用的小型应用框架 (application framework)。尽管还有一些地方存在不足、需要改进,但应用框架已经初现端倪了。
客户端的灵活变化
有此架构作为支撑,客户端将非常灵活。
例如,修改客户端的initViewports函数相关代码为:
则视口自动跑到右边。
修改为:
则视口自动跑到上边。
双视口应用
现在,我们来实现真正的双视口应用。
修改客户端的initViewports函数如下:
左视口视域为30o,从前右上方望向模型。右视口视域为60o,从前上方望向模型。
运行应用。
出问题了。只有右边的模型被渲染了,左边的模型消失不见了。
如果将最后一行代码屏蔽掉,则左边的模型得以渲染出来。
究其原因,问题出在Viewport类的render方法中:
每个视口均先清除帧缓冲区的全部内容,才渲染。应用程序的顺序为:清屏,渲染左视口的内容;至右视口时,清屏,再渲染右视口的内容。如此,在渲染右视口时,之前左视口的内容被清屏了。
如何让WebGL只在各个视口的范围内清屏?设定裁剪区域即可。
第一步,在客户端的initContext函数中,打开裁剪测试:
第二步,在Viewport类的render方法中,在清屏之前调用gl的scissor方法,并传入实例变量rect的相关属性值。
打开裁剪测试并将rect设置为裁剪区域后,WebGL将只在裁剪区域内清屏及渲染。这样,各个视口的内容将不再相互影响。
运行应用。