Cesium原理篇:6 Render模块(5: VAO&RenderState&Command)
VAO
VAO(Vertext Array Object),中文是顶点数组对象。之前在《Buffer》一文中,我们介绍了Cesium如何创建VBO的过程,而VAO可以简单的认为是基于VBO的一个封装,为顶点属性数组和VBO中的顶点数据之间建立了关联。我们来看一下使用示例:
var indexBuffer = Buffer.createIndexBuffer({ context : context, typedArray : indices, usage : BufferUsage.STATIC_DRAW, indexDatatype : indexDatatype }); var buffer = Buffer.createVertexBuffer({ context : context, typedArray : typedArray, usage : BufferUsage.STATIC_DRAW }); // 属性数组,当前是顶点数据z // 因此,该属性有3个分量XYZ // 值类型为float,4个字节 // 因此总共占3 *4= 12字节 attributes.push({ index : 0, vertexBuffer : buffer, componentsPerAttribute : 3, componentDatatype : ComponentDatatype.FLOAT, offsetInBytes : 0, strideInBytes : 3 * 4, normalize : false }); // 根据属性数组和顶点索引构建VAO var va = new VertexArray({ context : context, attributes : attributes, indexBuffer : indexBuffer });
如同,创建顶点数据和顶点索引的部分之前已经讲过,然后将顶点数据添加到属性数组中,并最终构建成VAO,使用方式很简单。
function VertexArray(options) { var vao; // 创建VAO if (context.vertexArrayObject) { vao = context.glCreateVertexArray(); context.glBindVertexArray(vao); bind(gl, vaAttributes, indexBuffer); context.glBindVertexArray(null); } } function bind(gl, attributes, indexBuffer) { for ( var i = 0; i < attributes.length; ++i) { var attribute = attributes[i]; if (attribute.enabled) { // 绑定顶点属性 attribute.vertexAttrib(gl); } } if (defined(indexBuffer)) { // 绑定顶点索引 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer._getBuffer()); } } attr.vertexAttrib = function(gl) { var index = this.index; // 之前通过Buffer创建的顶点数据_getBuffer gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer._getBuffer()); // 根据Attribute中的属性值来设置如下参数 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; } };
RenderState
指定DrawCommand的渲染状态,比如剔除,多边形偏移,深度检测等,通过RenderState统一管理:
function RenderState(renderState) { var rs = defaultValue(renderState, {}); var cull = defaultValue(rs.cull, {}); var polygonOffset = defaultValue(rs.polygonOffset, {}); var scissorTest = defaultValue(rs.scissorTest, {}); var scissorTestRectangle = defaultValue(scissorTest.rectangle, {}); var depthRange = defaultValue(rs.depthRange, {}); var depthTest = defaultValue(rs.depthTest, {}); var colorMask = defaultValue(rs.colorMask, {}); var blending = defaultValue(rs.blending, {}); var blendingColor = defaultValue(blending.color, {}); var stencilTest = defaultValue(rs.stencilTest, {}); var stencilTestFrontOperation = defaultValue(stencilTest.frontOperation, {}); var stencilTestBackOperation = defaultValue(stencilTest.backOperation, {}); var sampleCoverage = defaultValue(rs.sampleCoverage, {}); }
Drawcommand
前面我们讲了VBO/VAO,Texture,Shader以及FBO,终于万事俱备只欠东风了,当我们一切准备就绪,剩下的就是一个字:干。Cesium中提供了三类Command:DrawCommand、ClearCommand以及ComputeCommand。我们先详细的讲DrawCommand,同时也是最常用的。
var colorCommand = new DrawCommand({ owner : primitive, // TRIANGLES primitiveType : primitive._primitiveType }); colorCommand.vertexArray = primitive._va; colorCommand.renderState = primitive._rs; colorCommand.shaderProgram = primitive._sp; colorCommand.uniformMap = primitive._uniformMap; colorCommand.pass = pass;
如上是DrawCommand的创建方式,这里只有两个新的知识点,一个是owner属性,记录该DrawCommand是谁的菜,另外一个是pass属性。这是渲染队列的优先级控制。目前,Pass的枚举如下,具体内容下面后涉及:
var Pass = { ENVIRONMENT : 0, COMPUTE : 1, GLOBE : 2, GROUND : 3, OPAQUE : 4, TRANSLUCENT : 5, OVERLAY : 6, NUMBER_OF_PASSES : 7 };
创建完的DrawCommand会通过update函数,加载到frameState的commandlist队列中,比如Primitive中update加载drawcommand的伪代码:
Primitive.prototype.update = function(frameState) { var commandList = frameState.commandList; var passes = frameState.passes; if (passes.render) { var colorCommand = colorCommands[j]; commandList.push(colorCommand); } if (passes.pick) { var pickLength = pickCommands.length; var pickCommand = pickCommands[k]; commandList.push(pickCommand); } }
进入队列后就开始听从安排,随时准备上前线(渲染)。Scene会先对所有的commandlist会排序,Pass值越小优先渲染,通过Pass的枚举可以看到最后渲染的是透明的和overlay:
function createPotentiallyVisibleSet(scene) { for (var i = 0; i < length; ++i) { var command = commandList[i]; var pass = command.pass; // 优先computecommand,通过GPU计算 if (pass === Pass.COMPUTE) { computeList.push(command); } // overlay最后渲染 else if (pass === Pass.OVERLAY) { overlayList.push(command); } // 其他command else { var frustumCommandsList = scene._frustumCommandsList; var length = frustumCommandsList.length; for (var i = 0; i < length; ++i) { var frustumCommands = frustumCommandsList[i]; frustumCommands.commands[pass][index] = command; } } } }
根据渲染优先级排序后,会先渲染环境相关的command,比如skybox,大气层等,接着,开始渲染其他command:
function executeCommands(scene, passState) { // 地球 var commands = frustumCommands.commands[Pass.GLOBE]; var length = frustumCommands.indices[Pass.GLOBE]; for (var j = 0; j < length; ++j) { executeCommand(commands[j], scene, context, passState); } // 球面 us.updatePass(Pass.GROUND); commands = frustumCommands.commands[Pass.GROUND]; length = frustumCommands.indices[Pass.GROUND]; for (j = 0; j < length; ++j) { executeCommand(commands[j], scene, context, passState); } // 其他非透明的 var startPass = Pass.GROUND + 1; var endPass = Pass.TRANSLUCENT; for (var pass = startPass; pass < endPass; ++pass) { us.updatePass(pass); commands = frustumCommands.commands[pass]; length = frustumCommands.indices[pass]; for (j = 0; j < length; ++j) { executeCommand(commands[j], scene, context, passState); } } // 透明的 us.updatePass(Pass.TRANSLUCENT); commands = frustumCommands.commands[Pass.TRANSLUCENT]; commands.length = frustumCommands.indices[Pass.TRANSLUCENT]; executeTranslucentCommands(scene, executeCommand, passState, commands); // 后面在渲染Overlay }
接着,就是对每一个DrawCommand的渲染,也就是把之前VAO,Texture等等渲染到FBO的过程,这一块Cesium也封装的比较好,有兴趣的可以看详细代码,这里只讲一个逻辑,太困了。。。
DrawCommand.prototype.execute = function(context, passState) { // Contex开始渲染 context.draw(this, passState); }; Context.prototype.draw = function(drawCommand, passState) { passState = defaultValue(passState, this._defaultPassState); var framebuffer = defaultValue(drawCommand._framebuffer, passState.framebuffer); // 准备工作 beginDraw(this, framebuffer, drawCommand, passState); // 开始渲染 continueDraw(this, drawCommand); }; function beginDraw(context, framebuffer, drawCommand, passState) { var rs = defaultValue(drawCommand._renderState, context._defaultRenderState); // 绑定FBO bindFramebuffer(context, framebuffer); // 设置渲染状态 applyRenderState(context, rs, passState, false); // 设置ShaderProgram var sp = drawCommand._shaderProgram; sp._bind(); } function continueDraw(context, drawCommand) { // 渲染参数 var primitiveType = drawCommand._primitiveType; var va = drawCommand._vertexArray; var offset = drawCommand._offset; var count = drawCommand._count; var instanceCount = drawCommand.instanceCount; // 设置Shader中的参数 drawCommand._shaderProgram._setUniforms(drawCommand._uniformMap, context._us, context.validateShaderProgram); // 绑定VAO数据 va._bind(); var indexBuffer = va.indexBuffer; // 渲染 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); } } va._unBind(); }
ClearCommand
ClearCommand用于清空缓冲区的内容,包括颜色,深度和模板。用户在创建的时候,指定清空的颜色值等属性:
function Scene(options) { // Scene在构造函数中创建了clearCommand this._clearColorCommand = new ClearCommand({ color : new Color(), stencil : 0, owner : this }); }
然后在渲染中更新队列执行清空指令:
function updateAndClearFramebuffers(scene, passState, clearColor, picking) { var clear = scene._clearColorCommand; // 设置想要清空的颜色值,默认为(1,0,0,0,) Color.clone(clearColor, clear.color); // 通过execute方法,清空当前FBO对应的帧缓冲区 clear.execute(context, passState); }
然后,会根据你设置的颜色,深度,模板值来清空对应的帧缓冲区,代码好多啊,但很容易理解:
Context.prototype.clear = function(clearCommand, passState) { clearCommand = defaultValue(clearCommand, defaultClearCommand); passState = defaultValue(passState, this._defaultPassState); var gl = this._gl; var bitmask = 0; var c = clearCommand.color; var d = clearCommand.depth; var s = clearCommand.stencil; if (defined(c)) { if (!Color.equals(this._clearColor, c)) { Color.clone(c, this._clearColor); gl.clearColor(c.red, c.green, c.blue, c.alpha); } bitmask |= gl.COLOR_BUFFER_BIT; } if (defined(d)) { if (d !== this._clearDepth) { this._clearDepth = d; gl.clearDepth(d); } bitmask |= gl.DEPTH_BUFFER_BIT; } if (defined(s)) { if (s !== this._clearStencil) { this._clearStencil = s; gl.clearStencil(s); } bitmask |= gl.STENCIL_BUFFER_BIT; } var rs = defaultValue(clearCommand.renderState, this._defaultRenderState); applyRenderState(this, rs, passState, true); var framebuffer = defaultValue(clearCommand.framebuffer, passState.framebuffer); bindFramebuffer(this, framebuffer); gl.clear(bitmask); };
ComputeCommand
ComputeCommand需要配合ComputeEngine一起使用,可以认为是一个特殊的DrawCommand,它不是为了渲染,而是通过渲染机制,实现GPU的计算,通过Shader计算结果保存到纹理传出的一个过程,实现在Web前端高效的处理大量的数值计算,下面,我们通过学习之前ImageryLayer中对墨卡托影像切片动态投影的过程来了解该过程。
首先,创建一个ComputeCommand,定义这个计算过程前需要准备的内容,以及计算后对计算结果如何处理:
var computeCommand = new ComputeCommand({ persists : true, owner : this, // 执行前计算一下当前网格中插值点经纬度和墨卡托 // 并构建相关的参数,比如GLSL中的计算逻辑 // 传入的参数,包括attribute和uniform等 preExecute : function(command) { reprojectToGeographic(command, context, texture, imagery.rectangle); }, // 执行后的结果保存在outputTexture postExecute : function(outputTexture) { texture.destroy(); imagery.texture = outputTexture; finalizeReprojectTexture(that, context, imagery, outputTexture); imagery.releaseReference(); } });
还记得Pass中的Compute枚举吧,放在第一位,每次Scene.update时,发现有ComputeCommand都会优先计算,这个逻辑和DrawCommand一样,都会在update中push到commandlist中,比如在ImageryLayer中,则是在
queueReprojectionCommands方法完成的,而具体的执行也和DrawCommand比较相似,稍微有一些特殊和针对的部分,具体代码如下:
ComputeCommand.prototype.execute = function(computeEngine) { computeEngine.execute(this); }; ComputeEngine.prototype.execute = function(computeCommand) { if (defined(computeCommand.preExecute)) { // Ready? computeCommand.preExecute(computeCommand); } var outputTexture = computeCommand.outputTexture; var width = outputTexture.width; var height = outputTexture.height; // ComputeEngine是一个全局类,在Scene中可以获取 // 内部有一个Drawcommand // 把ComputeCommand中的参数赋给DrawCommand var drawCommand = drawCommandScratch; drawCommand.vertexArray = vertexArray; drawCommand.renderState = renderState; drawCommand.shaderProgram = shaderProgram; drawCommand.uniformMap = uniformMap; drawCommand.framebuffer = framebuffer; // Go! drawCommand.execute(context); if (defined(computeCommand.postExecute)) { // Over~ computeCommand.postExecute(outputTexture); } };
总结
Renderer系列告一段落,并没有涉及很多WebGL的语法层面,主要希望大家能对各个模块的作用有一个了解,并在这个了解的基础上,学习一下Cesium对WebGL渲染引擎的封装技巧。通过这一系列,个人很佩服Cesium的开发人员对OpenGL渲染引擎的理解,在完成这一系列的过程中,个人受益匪浅,也希望能对各位起到一个分享和帮助。
基于功能的面向函数的接口,封装成基于状态管理的面向对象的封装,方便了我们的使用和管理。但从中我们还是可以看到,WebGL在某些方面的薄弱,比如实例化和FBO的部分功能需要在WebGL2.0的规范下才支持,当然对此,我表示乐观,我感受到了WebGL标准化的快速发展。
另外,我也想到了用Three.js封装Cesium渲染引擎的可能,当然我对Three.js不了解,但随着不断学习Cesium。Renderer,我个人并不喜欢这个想法。我觉得在设计和封装上,Renderer已经很不错了,我们可以借鉴Three.js在功能和易用性上的特点,强化Cesium,而不是全盘否定重新造轮子。而且并不能因为点上的优势而进行面上的推倒,如果对这两个引擎都不了解,最好还是埋头学习少一点高谈阔论。基本功是顿悟不出来的。