最近研究Cesium的實例化,盡管該技術需要在WebGL2.0,也就是OpenGL ES3.0才支持。調試源碼的時候眼前一亮,發現VAO和glDrawBuffers都不是WebGL1.0的標准函數,都是擴展功能,看來WebGL2.0標准的推廣勢在必行啊。同時發現,通過ANGLE_instanced_arrays的擴展,也可以在WebGL1.0下實現實例化,創建實例化方法的代碼如下:
var glDrawElementsInstanced; var glDrawArraysInstanced; var glVertexAttribDivisor; var instancedArrays; // WebGL2.0標准直接提供了實例化接口 if (webgl2) { glDrawElementsInstanced = function(mode, count, type, offset, instanceCount) { gl.drawElementsInstanced(mode, count, type, offset, instanceCount); }; glDrawArraysInstanced = function(mode, first, count, instanceCount) { gl.drawArraysInstanced(mode, first, count, instanceCount); }; glVertexAttribDivisor = function(index, divisor) { gl.vertexAttribDivisor(index, divisor); }; } else { // WebGL1.0下 // 擴展ANGLE_instanced_arrays instancedArrays = getExtension(gl, ['ANGLE_instanced_arrays']); if (defined(instancedArrays)) { glDrawElementsInstanced = function(mode, count, type, offset, instanceCount) { instancedArrays.drawElementsInstancedANGLE(mode, count, type, offset, instanceCount); }; glDrawArraysInstanced = function(mode, first, count, instanceCount) { instancedArrays.drawArraysInstancedANGLE(mode, first, count, instanceCount); }; glVertexAttribDivisor = function(index, divisor) { instancedArrays.vertexAttribDivisorANGLE(index, divisor); }; } } // 涉及到實例化的三個方法 this.glDrawElementsInstanced = glDrawElementsInstanced; this.glDrawArraysInstanced = glDrawArraysInstanced; this.glVertexAttribDivisor = glVertexAttribDivisor; this._instancedArrays = !!instancedArrays;
通過這樣的封裝,Cesium.Context提供了標准的實例化方法,不需要用戶過多的關心WebGL標准的差異。而實例化的渲染也非常簡單,核心代碼如下:
functioncontinueDraw(context, drawCommand) { // …… var instanceCount = drawCommand.instanceCount; if (defined(indexBuffer)) { offset = offset * indexBuffer.bytesPerIndex; // offset in vertices to offset in bytes count = defaultValue(count, indexBuffer.numberOfIndices); if (instanceCount === 0) { context._gl.drawElements(primitiveType, count, indexBuffer.indexDatatype, offset); } else { context.glDrawElementsInstanced(primitiveType, count, indexBuffer.indexDatatype, offset, instanceCount); } } else { count = defaultValue(count, va.numberOfVertices); if (instanceCount === 0) { context._gl.drawArrays(primitiveType, offset, count); } else { context.glDrawArraysInstanced(primitiveType, offset, count, instanceCount); } } // …… }
是否實例化渲染,取決於你所構造的DrawCommand是否有實例化的信息,對應代碼中的drawCommand.instanceCount,如果你的實例化數目不為零,則進行實例化的渲染。因此,Context中對實例化進行了封裝,內部的渲染機制中,實例化和非實例化的渲染機制差別並不大。從應用的角度來看,我們並不需要關心Context的實現,而是通過構造DrawCommand來決定是否想要實例化渲染。
之前我們較詳細的介紹過Renderer.DrawCommand模塊,如果不清楚的回去再翻翻看,在VertexArray中實現了VAO中創建attr.vertexAttrib,這里有一個instanceDivisor屬性,這就是用來表示該attribute是否是實例化的divisor:
attr.vertexAttrib = function(gl) { var index = this.index; gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer._getBuffer()); gl.vertexAttribPointer(index, this.componentsPerAttribute, this.componentDatatype, this.normalize, this.strideInBytes, this.offsetInBytes); gl.enableVertexAttribArray(index); if (this.instanceDivisor > 0) { context.glVertexAttribDivisor(index, this.instanceDivisor); context._vertexAttribDivisors[index] = this.instanceDivisor; context._previousDrawInstanced = true; } };
根據OpenGL的定義,glVertexAttribDivisor在設置多實例渲染時,位於index位置的頂點着色器中頂點屬性是如何分配值到每個實例的。instanceDivisor如果是0,那該屬性的多實例特性將被禁用,其他值則表示頂點着色器中,每instanceDivisor個實力會分配一個新的屬性值。
可見,對於一個DrawCommand,實例化有三處特別的地方,一個是attribute的instanceDivisor屬性,用來確定實例化的頻率,一個是instanceCount,實例化的個數,最后一個,當然是頂點着色器了,attribute屬性傳到頂點着色器了,你得用才有效果啊。因為實例化的自由度很高,所以多數情況下需要你自己寫。
當然,目前Cesium用實例化的地方不多,只有BillboardCollection和3D Tiles中用到了,提供了完整的實現方法,不妨看看Cesium自己的調用方式,學習一下Cesium中如何使用實例化的。
ModelInstanceCollection
我們先看一下3D Tiles中實例化的實現方式,這個較為簡單,因為3D Tiles中的數據都是預處理的,可以直接加載,另外因為模型是gltf,自帶Shader,不需要過多的邏輯判斷。

上面是一個3D Tiles實例化的效果圖,可見除了位置不同,其他的都一致。實例化正是避免相同屬性之間的內存和顯存的調度,同時對不同的屬性的調度進行優化,從而提高渲染效率。我們先看一下數據處理的代碼:
functiongetVertexBufferData(collection, context, result) { var instances = collection._instances; var instancesLength = collection.length; var collectionCenter = collection._center; var vertexSizeInFloats = 12; if (!defined(result)) { result = new Float32Array(instancesLength * vertexSizeInFloats); } for (var i = 0; i < instancesLength; ++i) { var modelMatrix = instances[i].modelMatrix; // Instance matrix is relative to center var instanceMatrix = Matrix4.clone(modelMatrix, scratchMatrix); instanceMatrix[12] -= collectionCenter.x; instanceMatrix[13] -= collectionCenter.y; instanceMatrix[14] -= collectionCenter.z; var offset = i * vertexSizeInFloats; // First three rows of the model matrix result[offset + 0] = instanceMatrix[0]; result[offset + 1] = instanceMatrix[4]; result[offset + 2] = instanceMatrix[8]; result[offset + 3] = instanceMatrix[12]; result[offset + 4] = instanceMatrix[1]; result[offset + 5] = instanceMatrix[5]; result[offset + 6] = instanceMatrix[9]; result[offset + 7] = instanceMatrix[13]; result[offset + 8] = instanceMatrix[2]; result[offset + 9] = instanceMatrix[6]; result[offset + 10] = instanceMatrix[10]; result[offset + 11] = instanceMatrix[14]; } return result; }
代碼有點長,但不難理解,instancesLength是要進行實例化的實例個數,collectionCenter則是這些實例Collection的中心點,以前這些實例中都保存的是相對球心的模型矩陣,這樣構建instancesLength個DrawCommand,最終渲染到FBO中。但發現,這些實例基本一樣啊,以前是一筆一划的渲染出來,不然先弄一個印章,然后啪啪啪的蓋在不同的位置就可以了,這樣多快啊。所以現在要對他進行實例化的改造。這樣,當我在collectionCenter位置構造了一個實例(印章),你們告訴我距離中心點的偏移量,我就知道在哪里直接“蓋”這個實例了。所以,數據上,我們需要把這個矩陣改為相對collectionCenter的,getVertexBufferData就是做這個事情。接着在createVertexBuffer中我們將這個矩陣數據構建成一個VertexBuffer:
function createVertexBuffer(collection, context) { var vertexBufferData = getVertexBufferData(collection, context); collection._vertexBuffer = Buffer.createVertexBuffer({ context : context, typedArray : vertexBufferData, usage : dynamic ? BufferUsage.STREAM_DRAW : BufferUsage.STATIC_DRAW }); }
這樣,當我們創建好適合實例化的VertexBuffer后,就可以封裝實例化的屬性:
function createModel(collection, context) { var instancingSupported = collection._instancingSupported; var modelOptions; if (instancingSupported) { createVertexBuffer(collection, context); var instancedAttributes = { czm_modelMatrixRow0 : { index : 0, // updated in Model vertexBuffer : collection._vertexBuffer, componentsPerAttribute : 4, componentDatatype : ComponentDatatype.FLOAT, normalize : false, offsetInBytes : 0, strideInBytes : componentSizeInBytes * vertexSizeInFloats, instanceDivisor : 1 }, czm_modelMatrixRow1 : { index : 0, // updated in Model vertexBuffer : collection._vertexBuffer, componentsPerAttribute : 4, componentDatatype : ComponentDatatype.FLOAT, normalize : false, offsetInBytes : componentSizeInBytes * 4, strideInBytes : componentSizeInBytes * vertexSizeInFloats, instanceDivisor : 1 }, czm_modelMatrixRow2 : { index : 0, // updated in Model vertexBuffer : collection._vertexBuffer, componentsPerAttribute : 4, componentDatatype : ComponentDatatype.FLOAT, normalize : false, offsetInBytes : componentSizeInBytes * 8, strideInBytes : componentSizeInBytes * vertexSizeInFloats, instanceDivisor : 1 } }; modelOptions.precreatedAttributes = instancedAttributes; } collection._model = new Model(modelOptions); }
這里稍微有一些麻煩,將矩陣分解為3個vec4的attribute,分別對應czm_modelMatrixRow0~2,這里可以看到,每一個實例化的屬性中,instanceDivisor值為1,也就是一個實例更新一次,正好對應每一個實例的偏移量。 最后構造成Model(_precreatedAttributes)。
該Model實際上就是一個實例集合,在Model.update()中調用createVertexArrays方法創建VAO。這樣完成了一個arraybuffer(內存)->VertexBuffer(顯存)->Attributes->VertexArray的整個過程。最后綁定到DrawCommand中進行渲染。整個流程大概如下:
ModelInstanceCollection.prototype.update = function(frameState) { if (this._state === LoadState.NEEDS_LOAD) { this._state = LoadState.LOADING; this._instancingSupported = context.instancedArrays; // 數據處理,符合實例化的需要 createModel(this, context); } var model = this._model; // 創建VAO model.update(frameState); if (instancingSupported) { // 構造最終的DrawCommand // 指定instanceCount // 綁定VertexArray createCommands(this, modelCommands.draw, modelCommands.pick); } }
這樣,一個實例化的DrawCommand完成,以前需要Count個DrawCommand渲染的過程,只需要一個DrawCommand一次性渲染instanceCount個實例即可。當然,這里沒有給出3D Instance Tiles的頂點着色器代碼,只好自己想象這樣一個轉換代碼:a_position為原點,通過czm_modelMatrixRow0,czm_modelMatrixRow1,czm_modelMatrixRow2三個相對原點的偏移矩陣構造出czm_instanced_modelView模型試圖矩陣,最終結合投影矩陣計算出gl_Position。
Billboard:
這里主要介紹Billboard中實例化的設計和封裝,至於Billboard的整個過程,我們后續在介紹DataSource模塊時再詳細介紹。首先,我們要自己明白,對Billboard進行實例化渲染的意義在哪里,在目標明確的基礎下,我們才能總結這些實例之間的共同出和不同點,方能更好的設計:哪些屬性需要實例化,哪些屬性不需要。
大家可以自己思考一下,再往下看。因為這個涉及到對Billboard的理解,本篇主要集中在實例化上面,所以,直接給出Cesium的設計。
var attributeLocationsInstanced = { direction : 0, positionHighAndScale : 1, positionLowAndRotation : 2, // texture offset in w compressedAttribute0 : 3, compressedAttribute1 : 4, compressedAttribute2 : 5, eyeOffset : 6, // texture range in w scaleByDistance : 7, pixelOffsetScaleByDistance : 8 };
如上是Cesium中Billboard需要的attribute屬性,對每一個實例而言,direction都是一樣的,而其他八個屬性則不同。direction是公告板的四個頂點的相對位置(比例),對於所有公告板,這四個頂點之間的相對位置是一樣的,就好比一個印章,你只需要縮放一下相對位置就可以改變整體大小,移動一下位置就可以改變整體位置,旋轉也是如此。無論怎么變,Billboard的樣式都不會走樣。因此, 在BillboardCollection中,會默認創建唯一的公告板的direction:
functiongetIndexBufferInstanced(context) { var indexBuffer = context.cache.billboardCollection_indexBufferInstanced; if (defined(indexBuffer)) { return indexBuffer; } indexBuffer = Buffer.createIndexBuffer({ context : context, typedArray : new Uint16Array([0, 1, 2, 0, 2, 3]), usage : BufferUsage.STATIC_DRAW, indexDatatype : IndexDatatype.UNSIGNED_SHORT }); indexBuffer.vertexArrayDestroyable = false; context.cache.billboardCollection_indexBufferInstanced = indexBuffer; return indexBuffer; } function getVertexBufferInstanced(context) { var vertexBuffer = context.cache.billboardCollection_vertexBufferInstanced; if (defined(vertexBuffer)) { return vertexBuffer; } vertexBuffer = Buffer.createVertexBuffer({ context : context, typedArray : new Float32Array([0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0]), usage : BufferUsage.STATIC_DRAW }); vertexBuffer.vertexArrayDestroyable = false; context.cache.billboardCollection_vertexBufferInstanced = vertexBuffer; return vertexBuffer; }
如上,大家可以想象一個矩形(Billboard),中間畫一條對角線分成了兩個相接的三角形,小學幾何里面說過三角形的穩定性,因此,該矩形通過兩個三角形確保了樣式不變。我們先看看VertexBuffer,頂點數據為:[0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0],也就是Billboard的四個頂點,頂點索引為[0, 1, 2, 0, 2, 3],把四個點分成了兩個三角形(0, 1, 2,)和(0, 2, 3)。這樣,我們通過indexBuffer和vertexBuffer,構建了一個Billboard樣式,並將它保存在context.cache下,分別是billboardCollection_indexBufferInstanced和billboardCollection_vertexBufferInstanced,作為一個全局的單例。
就好比百米決賽,每個運動員都在高速奔跑,而攝像機也需要實時調整位置,保持一個最佳角度捕捉運動員的動作。一個公告板的的樣式確定了,但在不同的位置,角度以及公告報的大小,每個Billboard在不同的位置,這些屬性都會不同。因此這些屬性就是需要實例化的部分,並且這些屬性值(Buffer)需要實時的更新。
createVAF(context, numberOfBillboards, buffersUsage, instanced) { // 需要實例化的屬性 var attributes = [ { index : attributeLocations.positionHighAndScale, componentsPerAttribute : 4, componentDatatype : ComponentDatatype.FLOAT, usage : buffersUsage[POSITION_INDEX] }, { index : attributeLocations.positionLowAndRotation, componentsPerAttribute : 4, componentDatatype : ComponentDatatype.FLOAT, usage : buffersUsage[POSITION_INDEX] }, // …… { index : attributeLocations.pixelOffsetScaleByDistance, componentsPerAttribute : 4, componentDatatype : ComponentDatatype.FLOAT, usage : buffersUsage[PIXEL_OFFSET_SCALE_BY_DISTANCE_INDEX] }]; // direction不需要實例化 if (instanced) { attributes.push({ index : attributeLocations.direction, componentsPerAttribute : 2, componentDatatype : ComponentDatatype.FLOAT, vertexBuffer : getVertexBufferInstanced(context) }); } // 計算需要實例化的個數 // 也就是Billboard的個數 var sizeInVertices = instanced ? numberOfBillboards : 4 * numberOfBillboards; return new VertexArrayFacade(context, attributes, sizeInVertices, instanced); }
createVAF創建了結構體attributeLocationsInstanced所需要的所有屬性,也是渲染每一個Billboard實例時,在頂點着色器中需要的attribute屬性,這里主要有三個關鍵點:(1)只有direction屬性創建了vertexBuffer,而其他八個屬性是空的,需要實時的更新屬性值,也是需要實例化的屬性;(2)確定了instanceCount,也就是sizeInVertices;(3)最終Billboard所有attribute屬性(實例化和不需要實例化的direction)都交給了VertexArrayFacade。前兩點都很明確,現在就看VertexArrayFacade到底干了什么。
還是先思考一下,attribute都已經准備好了,下來應該是CreateVertexArray的過程了,而這里有兩處不同,第一,實例化所需要的八個屬性並沒有VertexBuffer,需要一個機制:(1)對所有實例更新這八個屬性值(2)屬性中有需要實例化的,需要在attribute中標識instanceDivisor屬性為true,而direction則不需要實例化。因此不難理解,VertexArrayFacade就是BillboardCollection和VertexArray之間的一個過渡,用來解決上面的兩個問題。
functionVertexArrayFacade(context, attributes, sizeInVertices, instanced) { var attrs = VertexArrayFacade._verifyAttributes(attributes); var length = attrs.length; for (var i = 0; i < length; ++i) { var attribute = attrs[i]; // 如果存在vertexBuffer,比如direction屬性 // 則不需要實時更新屬性值 // 放到precreatedAttributes,可以直接用 if (attribute.vertexBuffer) { precreatedAttributes.push(attribute); continue; } // 沒有vertexBuffer的 // 則放到attributesForUsage // 后面對這些屬性進行賦值 usage = attribute.usage; attributesForUsage = attributesByUsage[usage]; if (!defined(attributesForUsage)) { attributesForUsage = attributesByUsage[usage] = []; } attributesForUsage.push(attribute); } }
如上對attribute根據是否需要實例化,進行了區分。然后在渲染時,在更新隊列中更新數據:
BillboardCollection.prototype.update = function(frameState) { if (billboardsLength > 0) { // 創建Attribute屬性 this._vaf = createVAF(context, billboardsLength, this._buffersUsage, this._instanced); vafWriters = this._vaf.writers; // 數據有更新時,需要重寫實例化的屬性值 for (var i = 0; i < billboardsLength; ++i) { var billboard = this._billboards[i]; billboard._dirty = false; writeBillboard(this, context, textureAtlasCoordinates, vafWriters, billboard); } // 創建實例化的VAO,這里使用同一個頂點索引,也就是用一個相同的樣式 this._vaf.commit(getIndexBuffer(context)); } }
這里,writeBillboard通過vafWriters方法,將實例化的屬性值寫入到arraybuffer中,這里就不詳細介紹過程了。簡單說就三個過程:創建,寫,提交。 首先在VertexArrayFacade初始化中,最終會調用_resize,這里雖然並不知道實例化attribute屬性的值,但所占內存的大小是明確的,所以會在內存中創建一個屬性值均為0的arraybuffer。然后在createWriters中實現了寫的方法,VertexArrayFacade通過閉包的方式綁定到writers屬性中,BillboardCollection中對應:vafWriters = this._vaf.writers,實現屬性值的寫操作。最后,通過commit提交,創建VAO,將內存中的Buffer傳遞到顯存中。
VertexArrayFacade.prototype.commit = function(indexBuffer){ for (i = 0, length = allBuffers.length; i < length; ++i) { buffer = allBuffers[i]; // 創建VertexBuffer // 將寫到arraybuffer中的屬性值綁定到顯存中 recreateVA = commit(this, buffer) || recreateVA; } // 創建attribute,指定實例化屬性 // instanceDivisor : instanced ? 1 : 0 VertexArrayFacade._appendAttributes(attributes, buffer, offset, this._instanced); // 添加之前已經創建好的非實例化的attribute attributes = attributes.concat(this._precreated); // 創建VAO va.push({ va : new VertexArray({ context : this._context, attributes : attributes, indexBuffer : indexBuffer }), indicesCount : 1.5 * ((k !== (numberOfVertexArrays - 1)) ? (CesiumMath.SIXTY_FOUR_KILOBYTES - 1) : (this._size % (CesiumMath.SIXTY_FOUR_KILOBYTES - 1))) }); }
如上,完成了BillboardCollection中VAO的創建。最后一步,就是頂點着色器中如何使用這些屬性,這里主要看一個思路,看一下實例化和非實例化之間的區別,以及如何配合:
vec4 computePositionWindowCoordinates(vec4 positionEC, vec2 imageSize, float scale, vec2 direction, vec2 origin, vec2 translate, vec2 pixelOffset, vec3 alignedAxis, bool validAlignedAxis, float rotation, bool sizeInMeters) { vec2 halfSize = imageSize * scale * czm_resolutionScale; // 通過direction,判斷當前的頂點位於四個頂點中的哪一個 // 左上,左下,右下,右上? // 所有實例的direction都是一致的,因此該屬性不需要實例化 halfSize *= ((direction * 2.0) - 1.0); // 下面根據實例化的屬性來計算該點的真實位置 …… }
總結
實例化是一個強大功能,但性能的提升往往需要跟數據緊密聯系,需要有一個數據規范的前提,所以Cesium目前對實例化應用的地方並不多,但即使這樣,Cesium也意識到必要性,即使WebGL1.0規范並不支持的情況下,也通過擴展的方式來支持。當然,最重要的是能夠學習到Cesium對實例化的封裝和應用,以及如何理解,哪些不同的attribute需要實例化。
