本篇讲述mini3d.js对静态模型的封装,目前支持三角形列,使用或不使用索引,
暂不支持子模型。涉及到的类有Mesh
, VertexBuffer
, IndexBuffer
, VertexFormat
,以及预定义顶点语义VertexSemantic
。
一个Mesh由一个VertexBuffer
和零或一个IndexBuffer
构成。模型所有的顶点的属性数据采用交错数组的方式存放在VertexBuffer中。即:
[position1, normal1, uv1, position2, normal2, uv2, ...., position N, normal N, uvN]
这种方式。目前顶点的所有属性统一采用Float32格式。这是为了简化采取的妥协。
如果属性的格式不一致,则需要分开放到不同的数组中。目前不支持多数组(即多个vbo)。现在的设计是个极简的设计,可以支持绝大多数情况。未来如果要支持更多的功能,则可能修改这个设计,例如如果想支持动态修改模型属性并重新提交到显卡,为了避免大量数据的重复提交,可能把不变的数据和需要动态修改的数据分开,放到两个数组(vbo)中。
上面顶点具体使用哪些属性,如何排列,则是通过VertexFormat
定义。VertexFormat
由一组VertexSemantic
构成,semantic是一个字符串,除了在mini3d.VertexSemantic
中预定义的属性,还可使用任何自定义的属性字符串。定义semantic一方面是为了描述顶点属性的格式,另一方面是为了和Vertex Shader的输入attribute进行关联。
至于IndexBuffer
是可选的,当手创建简单的2D面片,例如UI时,可以不使用索引。Mesh会根据是否提供IndexBuffer使用不同的draw call。Index的格式根据传入的索引数据的数量自动选择Uint8
或Uint16
。mini3d.js没有处理索引数量超过65536的情况。
要使用WebGL进行渲染,必先提供数据。模型由顶点构成,顶点除了坐标这个最基本的数据外,还可能有很多其他的数据,例如法线、切线、纹理坐标、颜色等等。除了这些常用的数据,还可能有自定义数据。因为Shader具有极高的灵活性,除了完成常用的渲染算法外,还可能采用各种独一无二的渲染方式,甚至将逻辑置于其中。我们的目标是能支持任意类型的顶点数据,方便程序进行扩展。由于顶点数据是和Vertex Shader的attribute对应的,所以还需要管理好这个对应关系。VertexFormat类如下:
class VertexFormat{
constructor(){
this.attribs = [];
this.attribSizeMap = {
};
this._vertexSize = 0;
}
addAttrib(attribSemantic, size){
this.attribs.push(attribSemantic);
this.attribSizeMap[attribSemantic] = size;
}
getVertexSize(){
if(this._vertexSize === 0){
for(let i=0; i<this.attribs.length; ++i){
let semantic = this.attribs[i];
this._vertexSize += this.attribSizeMap[semantic];
}
}
return this._vertexSize;
}
}
其内部使用this.attribs
数组存放顶点属性的语义(semantic),semantic是一个字符串,mini3d.js预定义了一些:
let VertexSemantic = {
POSITION : 'position',
NORMAL : 'normal',
TANGENT : 'tangent',
COLOR : 'color',
UV0 : 'uv0',
UV1 : 'uv1',
UV2 : 'uv2',
UV3 : 'uv3'
}
也可以填写自定义的。所谓语义,就是词语的含义,借用到这儿表示这个顶点属性的含义,他是表示的位置,还是表示的法线,或者是一个自定义的含义。this.attribs
是一个数组,因此同时也能表示这些语义的顺序,最终VertexBuffer
生成提交到vbo的顶点数据时,会按照这个顺序排列顶点属性。这个顺序重要吗?也许不重要,但是提供了一个可能性吧。以前有说法某某显卡,采用某种顺序提交顶点会有优化。
addAttrib(attribSemantic, size)
方法添加一个属性语义,同时指定了这个属性的成员数量。上面说过,Mesh的顶点属性统一采用Float32类型,以便把他们打包到一个VBO中。而这儿的size就是指定某属性用几个Float32表示。例如:
format.addAttrib(mini3d.VertexSemantic.POSITION, 3);
表示添加一个属性表示顶点坐标,使用3个Float32。
getVertexSize()
方法会返回整个顶点所有属性的Float32总数。
VertexBuffer由Mesh内部使用。主要的设计考虑是分别设置各个属性的数据,然后根据顶点格式进行_compile得到javascript的数组,然后创建一个Flaot32Array并上传到vbo中。
所以核心是_compile函数和upload函数。
setData(semantic, data)
为某个属性设置所有的顶点的数据。也就是说一个模型中的所有顶点的某类数据是放在一个javascript数组中设置进来的。这么做对于手工创建模型比较方便,对于载入模型是否方便要看模型文件的格式。如果有问题也会提供逐顶点设置数据的接口。注意的是,虽然各个属性的数据是分开多个数组设置的,但是最终提交到VBO的是一个交错数组。在_compile
这个内部函数中会生成这个数组。
unpload()
方法首先调用_compile()
生成所有顶点数据的交错数组,然后创建一个Float32Array并且上传到this._vbo中。之后会释放这个交错数组。
这是对gl.vertexAttribPointer
和gl.enableVertexAttribArray
的封装。顶点shader需要知道每个attribute的数据从何而来,在绘制前会绑定VBO,但是属性在VBO中的具体位置和size都需要指定。bindAttrib(shader)
方法封装了这个功能。之所以传入shader,是要使用shader获取到attriube的location。
let location = shader.getAttributeLocation(semantic);
获取location同样使用了semantic。所以正如上文说到semantic起到联系VertexBuffer和Shader的作用。由于我们还没封装材质,所以这地方暂时用shader。
目前基本的简化就是图元只使用gl.TRIANGLES。
setData(data)
方法用于接受索引数据。
upload()
方法会根据索引数量生成Uint8Array或Uint16Array
Mesh只是提供了一个面向使用着的接口,主要的功能都在VertexBuffer和IndexBuffer中。
constructor(vertexFormat)
构造函数中传入一个VertexFormat实例,内部会创建一个VertexBuffer。
setVertexData(semantic, data)
内部调用了VertexBuffer的setData方法。
setTriangles(data)
如果调用则会创建出IndexBuffer,并调用其setData方法。
upload()
方法内部调用了VertexBuffer和IndexBuffer的upload。
render(shader){
gl.bindBuffer(gl.ARRAY_BUFFER, this._vertexBuffer.vbo);
this._vertexBuffer.bindAttrib(shader);
if(this._indexBuffer){
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this._indexBuffer.vbo);
gl.drawElements(this._indexBuffer.mode, this._indexBuffer.indexCount, this._indexBuffer.type, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
} else {
gl.drawArrays(gl.TRIANGLES, 0, this._vertexBuffer.vertexCount);
}
this._vertexBuffer.unbindAttrib(shader);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}
执行WebGL的bindBuffer,然后调用VertexBuffer的bindAttrib(shader),之后执行draw call。
子模型是未来可能要支持的功能,模型之所以要切分为子模型,是为了采用不同的材质。这个还是挺常见的。实现方式,正常来说,是通过索引中不同的分段来指定子模型使用的顶点。毕竟子模型是模型的一部分,子模型之间会共享顶点,因此Vertex Buffer还是用同一个。当然这样做子模型顶点就不能使用不同的顶点属性了,必须提供顶点属性的并集,但是这应该不常见。