在介紹Renderer的第一篇,我就提到WebGL1.0對應的是OpenGL ES2.0,也就是可編程渲染管線。之所以單獨強調這一點,算是為本篇埋下一個伏筆。通過前兩篇,我們介紹了VBO和Texture兩個比較核心的WebGL概念。假設生產一輛汽車,VBO就相當於這個車的骨架,紋理相當這個車漆,但有了骨架和車漆還不夠,還需要一台機器人來加工,最終才能成產出這輛汽車。而Shader模塊就是負責這個生產的過程,加工參數(VBO,Texture),執行渲染任務。
這里假設大家對Shader有一個基本的了解,這一塊內容也很多,不可能簡單兩句輕描淡寫就豁然開朗,而且我也沒有進行過系統的學習,所以就不班門弄斧了。進入主題,來看看Cesium對Shader的封裝。
圖1:ES2.0可編程渲染管線
上圖是可編程渲染管線的一個大概流程,我們關注的兩個橙色的圓角矩形部分,分別是頂點着色器和片源着色器。既然是可編程渲染管線,面向Shader的開發者提供了一種稱為GLSL的語言,如果你懂C的話,兩者語法是相當的,所以從語法層面學習成本不大。
ShaderSource創建
首先,Cesium提供了ShaderSource類來加載GLSL代碼,我們來看一下它對應的拷貝構造函數:
ShaderSource.prototype.clone = function() { return new ShaderSource({ sources : this.sources, defines : this.defines, pickColorQuantifier : this.pickColorQualifier, includeBuiltIns : this.includeBuiltIns }); };
- sources
必須,代碼本身,這里是一個數組,可以是多個代碼片段的疊加 - defines
非必須,執行該代碼時聲明的預編譯宏 - pickColorQualifier
非必須,當需要點擊選中地物時設置此參數,值為'uniform',下面會介紹其大概 - includeBuiltIns
非必須,默認為true,認為需要加載Cesium自帶的GLSL變量或function,下面會詳細介紹
在使用上,通常只需要指定前兩個參數,就可以創建一個頂點或片元着色器,比如在Globe中創建渲染地球的着色器代碼就是這么的簡單:
// 頂點着色器 this._surfaceShaderSet.baseVertexShaderSource = new ShaderSource({ sources : [GroundAtmosphere, GlobeVS] }); // 片元着色器 this._surfaceShaderSet.baseFragmentShaderSource = new ShaderSource({ sources : [GlobeFS] });
ShaderSource腳本加載
當然用起來簡單,但其內部實現還是有些復雜的,在介紹ShaderSource前需要先了解兩個知識點:CzmBuiltins&AutomaticUniforms。
CzmBuiltins
Cesium中提供了一些常用的GLSL文件,文件夾結構如下圖:
圖2:BuiltIn文件夾清單
如圖所示主要分為三類(常量,方法,結構體),這些都是Cesium框架內部比較常用的基本結構和方法,屬於內建類型,它們的特點是前綴均為czm_並且通過CzmBuiltins.js(打包時gulp會自動生成該文件)引用所有內建的GLSL代碼:
// 1 常量,例如:1 / Pi onst float czm_oneOverPi = 0.3183098861837907; // 方法,例如:八進制解碼,地形數據中用於數據壓縮 vec3 czm_octDecode(vec2 encoded) { encoded = encoded / 255.0 * 2.0 - 1.0; vec3 v = vec3(encoded.x, encoded.y, 1.0 - abs(encoded.x) - abs(encoded.y)); if (v.z < 0.0) { v.xy = (1.0 - abs(v.yx)) * czm_signNotZero(v.xy); } return normalize(v); } // 結構體,例如:材質 struct czm_material { vec3 diffuse; float specular; float shininess; vec3 normal; vec3 emission; float alpha; };
AutomaticUniforms
然而作為參數而言,僅僅有這些Const常量還是不夠的,比如在一個三維場景中,隨着位置的變化,相機的狀態也是需要更新的,比如ModelViewMatrix,ProjectMatrix以及ViewPort等變量通常也需要參與到GLSL的計算中,Cesium提供了AutomaticUniform類,用來封裝這些內建的變量,構造函數如下:
function AutomaticUniform(options) { this._size = options.size; this._datatype = options.datatype; this.getValue = options.getValue; }
所有的內部變量都可以基於該構造函數創建,並添加到AutomaticUniforms數組中,並且在命名上也遵守czm_*的格式,通過命名就可以知道該變量是不是內建的,如果是,則從CzmBuiltins和AutomaticUniforms對應的列表(創建列表並維護的過程則是在ShaderSource中完成的,下面會講)中找其對應的值就可以,這樣,Cesium內部自動調用這些變量而不需要用戶來處理,如果不是,則需要用戶自己定義一個uniformMap的數組來自己維護。如下是AutomaticUniforms的代碼片段,可以看到AutomaticUniforms中創建了czm_viewport變量,類型是vec4,並提供了getValue的方式,負責傳值。
var AutomaticUniforms = { czm_viewport : new AutomaticUniform({ size : 1, datatype : WebGLConstants.FLOAT_VEC4, getValue : function(uniformState) { return uniformState.viewportCartesian4; } }) } return AutomaticUniforms;
但這還有一個問題,只提供了getValue的方式,可以把值傳到GLSL中,但這個值是怎么獲取的,也就是setValue是如何實現,而且不需要用戶來關心。如果你看的足夠自信,會發現getValue中有一個uniformState參數,正是UniformState這個類的功勞了,Scene在初始化時會創建該屬性,而UniformState提供了update方法,在每一幀Render中都會更新這些變量值,不需要用戶自己來維護。
綜上所述,也就是Cesium內部有一套內建的變量,常量,方法和結構體,這些內容之間有一套完整的機制保證他們的正常運作,而ShaderSource的第一個作用就是在初始化的時候聲明_czmBuiltinsAndUniforms屬性,並加載CzmBuiltins和AutomaticUniforms中的內建屬性,建立一個全局的黃頁,為整個程序服務。另外要強調的是,這個過程是在加載ShaderSource.js腳本時執行的,只會運行一次,不需要每次new ShaderSource的時候執行。
ShaderSource._czmBuiltinsAndUniforms = {}; // 合並automatic uniforms和Cesium內建實例 // CzmBuiltins是打包時自動創建的,里面包括所有內建實例的類型和命名 for ( var builtinName in CzmBuiltins) { if (CzmBuiltins.hasOwnProperty(builtinName)) { ShaderSource._czmBuiltinsAndUniforms[builtinName] = CzmBuiltins[builtinName]; } } // AutomaticUniforms數組是在AutomaticUniforms.js中創建並返回 for ( var uniformName in AutomaticUniforms) { if (AutomaticUniforms.hasOwnProperty(uniformName)) { var uniform = AutomaticUniforms[uniformName]; if (typeof uniform.getDeclaration === 'function') { ShaderSource._czmBuiltinsAndUniforms[uniformName] = uniform.getDeclaration(uniformName); } } }
Shader創建
上面介紹了ShaderSource的創建,當用戶創建完VertexShaderSource和FragmentShaderSource后,下面就要創建ShaderProgram,將這兩個ShaderSource關聯起來。如下是SkyBox中創建ShaderProgram的示例代碼:
command.shaderProgram = ShaderProgram.fromCache({
context : context,
vertexShaderSource : SkyBoxVS,
fragmentShaderSource : SkyBoxFS,
attributeLocations : attributeLocations
});
vertexShaderSource和fragmentShaderSource都屬於之前我們提到的ShaderSource概念,attributeLocations則對應之前的VBO中VertexBuffer。GLSL中變量分為兩種,一類是attribute,比如位置,法線,紋理坐標這些,每一個頂點對應的值都不同,一類是uniform,跟頂點無關,值都相同的。這里需要傳入attribute變量,而uniform在渲染時才會指定。我們來看一下fromCache的內部實現,詳細的介紹一下:
ShaderProgram.fromCache = function(options) { // Cesium提供了ShaderCache緩存機制,可以重用ShaderProgram return options.context.shaderCache.getShaderProgram(options); }; ShaderCache.prototype.getShaderProgram = function(options) { // 合並該ShaderProgram所用到的頂點和片元着色器的代碼 var vertexShaderText = vertexShaderSource.createCombinedVertexShader(); var fragmentShaderText = fragmentShaderSource.createCombinedFragmentShader(); // 創建Cache緩存中Key-Value中的Key值 var keyword = vertexShaderText + fragmentShaderText + JSON.stringify(attributeLocations); var cachedShader; // 如果已存在,則直接用 if (this._shaders[keyword]) { cachedShader = this._shaders[keyword]; // No longer want to release this if it was previously released. delete this._shadersToRelease[keyword]; } else { // 如果不存在,則需要創建新的ShaderProgram var context = this._context; var shaderProgram = new ShaderProgram({ gl : context._gl, logShaderCompilation : context.logShaderCompilation, debugShaders : context.debugShaders, vertexShaderSource : vertexShaderSource, vertexShaderText : vertexShaderText, fragmentShaderSource : fragmentShaderSource, fragmentShaderText : fragmentShaderText, attributeLocations : attributeLocations }); // Key-Value中的Value值 cachedShader = { cache : this, shaderProgram : shaderProgram, keyword : keyword, count : 0 }; 添加到Cache中,並更新該Cache容器內總的shader數目 shaderProgram._cachedShader = cachedShader; this._shaders[keyword] = cachedShader; ++this._numberOfShaders; } // 該ShaderProgram的引用計數值 ++cachedShader.count; return cachedShader.shaderProgram; };
不難發現,fromCache最終是通過shaderCache.getShaderProgram方法實現ShaderProgram的創建,從這可以看出Cesium提供了ShaderCache緩存機制,可以重用ShaderProgram,通過雙面的代碼注釋可以很好的理解這個過程。另外,1通過createCombinedVertexShader/createCombinedFragmentShader方法,生成最終的GLSL代碼(下面會詳細介紹),並2創建ShaderProgram。下面討論一下1和2的具體實現。
文件合並
前面我們提到Cesium提供了豐富的內建函數和變量,這樣提高了代碼的重用性,正因為如此,很可以出現一個GLSL代碼是由多個代碼片段組合而成的,因此ShaderSource.sources是一個數組類型,可以加載多個GLSL文件。這樣,自然要提供一個多文件合並成一個GLSL代碼的方法。
但合並代碼並不只是單純文本的疊加,算是一個簡易的語法解析器,特別是一些內建變量的聲明,我們來看一下combine代碼的大致邏輯:
function combineShader(shaderSource, isFragmentShader) { var i; var length; // sources中的文本合並 var combinedSources = ''; var sources = shaderSource.sources; if (defined(sources)) { for (i = 0, length = sources.length; i < length; ++i) { // #line needs to be on its own line. combinedSources += '\n#line 0\n' + sources[i]; } } // 去掉代碼中的注釋部分 combinedSources = removeComments(combinedSources); // 最終的GLSL代碼 var result = ''; // 支持的版本號 if (defined(version)) { result = '#version ' + version; } // 添加預編譯宏 var defines = shaderSource.defines; if (defined(defines)) { for (i = 0, length = defines.length; i < length; ++i) { var define = defines[i]; if (define.length !== 0) { result += '#define ' + define + '\n'; } } } // 追加內建變量 if (shaderSource.includeBuiltIns) { result += getBuiltinsAndAutomaticUniforms(combinedSources); } result += '\n#line 0\n'; // 追加combinedSources中的代碼 result += combinedSources; return result; }
注釋部分是基本的邏輯,1合並sources中的文件,2刪除注釋,3提取版本信息,4拼出最終的代碼。4.1版本聲明,4.2預編譯宏,4.3內建變量的聲明,4.4加載步驟1中的代碼。這里的邏輯都還比較容易理解,但4.3,內建變量的聲明還是比較復雜的,我們專門介紹一下。
function getBuiltinsAndAutomaticUniforms(shaderSource) { var dependencyNodes = []; // 獲取Main根節點 var root = getDependencyNode('main', shaderSource, dependencyNodes); // 生成該root依賴的所有節點,保存在dependencyNodes generateDependencies(root, dependencyNodes); // 根據依賴關系排序 sortDependencies(dependencyNodes); // 創建需要的內建變量聲明 var builtinsSource = ''; for (var i = dependencyNodes.length - 1; i >= 0; --i) { builtinsSource = builtinsSource + dependencyNodes[i].glslSource + '\n'; } return builtinsSource.replace(root.glslSource, ''); }
該部分的重點在於對dependencyNode的維護,我們先看看該節點的結構:
dependencyNode = { name : name, glslSource : glslSource, dependsOn : [], requiredBy : [], evaluated : false };
如下圖,是根節點對應的值:
其中,name就是名稱,根節點就是main函數入口;glslSource則是其內部的代碼;dependsOn是他依賴的節點;requiredBy是依賴他的節點;evaluated用來表示該節點是否已經解析過。有了根節點root,下面就是順藤摸瓜,最終構建出所有節點的隊列,這就是generateDependencies函數做的事情,偽代碼如下:
function generateDependencies(currentNode, dependencyNodes) { // 更新標識,當前節點已經結果過 currentNode.evaluated = true; // 正則表達式,搜索當前代碼中符合czm_*的所有內建變量或函數 var czmMatches = currentNode.glslSource.match(/\bczm_[a-zA-Z0-9_]*/g); if (defined(czmMatches) && czmMatches !== null) { // remove duplicates czmMatches = czmMatches.filter(function(elem, pos) { return czmMatches.indexOf(elem) === pos; }); // 遍歷czmMatches找到的所有符合規范的變量,建立依賴關系,是一個雙向鏈表 czmMatches.forEach(function(element) { if (element !== currentNode.name && ShaderSource._czmBuiltinsAndUniforms.hasOwnProperty(element)) { var referencedNode = getDependencyNode(element, ShaderSource._czmBuiltinsAndUniforms[element], dependencyNodes); // currentNodetNode依賴referencedNode currentNode.dependsOn.push(referencedNode); // referencedNode被currentNodetNode依賴 referencedNode.requiredBy.push(currentNode); // recursive call to find any dependencies of the new node generateDependencies(referencedNode, dependencyNodes); } }); } }
有了這個節點隊列還並不能滿足要求,因為隊列中的元素是按照在glsl代碼中出現的先后順序來解析的,而元素之間也存在一個依賴關系,所以我們需要一個過程,把這個無序隊列轉化為一個有依賴關系的雙向鏈表,這就是sortDependencies函數的工作。這其實是一個樹的廣度優先的遍歷,左右上的順序,遍歷的過程中會解除requiredBy的關聯,有興趣的可以看一下源碼。最后會判斷是否有循環依賴的錯誤情況。也算是一個依賴關系的語法解析器。
至此,ShaderSource基本完成了自己的核心使命,當然,如果是拾取狀態,屬於特殊情況,則會更新片源着色器的代碼,對於選中的地物賦予選中風格(顏色),對應的函數為:ShaderSource.createPickFragmentShaderSource。
ShaderProgram創建
有了最終版本的着色器代碼后,終於可以創建ShaderProgram了,構造函數如下,本身也是一個空殼,只有在渲染中第一次使用該ShaderProgram時進行WebGL層面的調用,避免不必要的資源消耗:
function ShaderProgram(options) { this._vertexShaderSource = options.vertexShaderSource; this._vertexShaderText = options.vertexShaderText; this._fragmentShaderSource = options.fragmentShaderSource; this._fragmentShaderText = modifiedFS.fragmentShaderText; this.id = nextShaderProgramId++; }
渲染狀態
主要介紹渲染狀態中ShaderProgram的相關操作。
綁定ShaderProgram
代碼如上,在渲染時會先綁定該ShaderProgram,如果是第一次則會初始化。注釋是里面的關鍵邏輯,應該比較容易理解,這里值得強調的是對uniform的區分,方便后面渲染中參數的傳值。
ShaderProgram.prototype._bind = function() { // 初始化 initialize(this); // 綁定 this._gl.useProgram(this._program); }; function initialize(shader) { // 如果已經創建,則不需要初始化 if (defined(shader._program)) { return; } var gl = shader._gl; // 創建該Program,如果編譯有錯,則拋出異常 var program = createAndLinkProgram(gl, shader, shader._debugShaders); // 獲取attribute變量的數目 var numberOfVertexAttributes = gl.getProgramParameter(program, gl.ACTIVE_ATTRIBUTES); // 獲取uniform變量的列表 var uniforms = findUniforms(gl, program); // 根據czm_*規則區分uniform,分為自定義uniform和內建uniform var partitionedUniforms = partitionUniforms(shader, uniforms.uniformsByName); // 保存屬性 shader._program = program; shader._numberOfVertexAttributes = numberOfVertexAttributes; shader._vertexAttributes = findVertexAttributes(gl, program, numberOfVertexAttributes); shader._uniformsByName = uniforms.uniformsByName; shader._uniforms = uniforms.uniforms; shader._automaticUniforms = partitionedUniforms.automaticUniforms; shader._manualUniforms = partitionedUniforms.manualUniforms; shader.maximumTextureUnitIndex = setSamplerUniforms(gl, program, uniforms.samplerUniforms); }
findUniforms
createUniform封裝了所有Uniform類型的創建方法,並提供set函數,實現變量值和WebGL之間的傳遞。構造函數如下:
function createUniform(gl, activeUniform, uniformName, location) { switch (activeUniform.type) { case gl.FLOAT: return new UniformFloat(gl, activeUniform, uniformName, location); case gl.FLOAT_VEC2: return new UniformFloatVec2(gl, activeUniform, uniformName, location); case gl.FLOAT_VEC3: return new UniformFloatVec3(gl, activeUniform, uniformName, location); case gl.FLOAT_VEC4: return new UniformFloatVec4(gl, activeUniform, uniformName, location); case gl.SAMPLER_2D: case gl.SAMPLER_CUBE: return new UniformSampler(gl, activeUniform, uniformName, location); case gl.INT: case gl.BOOL: return new UniformInt(gl, activeUniform, uniformName, location); case gl.INT_VEC2: case gl.BOOL_VEC2: return new UniformIntVec2(gl, activeUniform, uniformName, location); case gl.INT_VEC3: case gl.BOOL_VEC3: return new UniformIntVec3(gl, activeUniform, uniformName, location); case gl.INT_VEC4: case gl.BOOL_VEC4: return new UniformIntVec4(gl, activeUniform, uniformName, location); case gl.FLOAT_MAT2: return new UniformMat2(gl, activeUniform, uniformName, location); case gl.FLOAT_MAT3: return new UniformMat3(gl, activeUniform, uniformName, location); case gl.FLOAT_MAT4: return new UniformMat4(gl, activeUniform, uniformName, location); default: throw new RuntimeError('Unrecognized uniform type: ' + activeUniform.type + ' for uniform "' + uniformName + '".'); } }
這樣,我們找到所有的uniforms,並根據其對應的type來封裝,set方法相當於虛函數,不同的類型有不同的實現方法,這樣的好處是在傳值時直接調用set方法,而不需要因為類型的不同而分散注意力。
_setUniforms
我們在ShaderProgram初始化的時候,已經完成了對attribute變量的賦值過程,現在則是對uniform變量的賦值。這里分為兩種情況,自定義和內建uniform兩種情況,嚴格說還包括紋理的samplerUniform變量。
uniformMap
對應自定義的變量,會構造一個uniformMap賦給DrawCommand(后續會介紹,負責整個渲染的調度,將VBO,Texture,Framebuffer和Shader串聯起來),如下是一個最簡單的UniformMap示例:
var uniformMap = { u_initialColor : function() { return this.properties.initialColor; } }
其中u_initialColor就是該uniform變量的name,return則是其返回值。接下來我們來看看setUniforms代碼:
ShaderProgram.prototype._setUniforms = function(uniformMap, uniformState, validate) { var len; var i; if (defined(uniformMap)) { var manualUniforms = this._manualUniforms; len = manualUniforms.length; for (i = 0; i < len; ++i) { var mu = manualUniforms[i]; mu.value = uniformMap[mu.name](); } } var automaticUniforms = this._automaticUniforms; len = automaticUniforms.length; for (i = 0; i < len; ++i) { var au = automaticUniforms[i]; au.uniform.value = au.automaticUniform.getValue(uniformState); } // It appears that assigning the uniform values above and then setting them here // (which makes the GL calls) is faster than removing this loop and making // the GL calls above. I suspect this is because each GL call pollutes the // L2 cache making our JavaScript and the browser/driver ping-pong cache lines. var uniforms = this._uniforms; len = uniforms.length; for (i = 0; i < len; ++i) { uniforms[i].set(); } };
首先,無論是manualUniforms還是automaticUniforms,都是經過createUniform封裝后的uniform,這里更新它們的value,通過uniformMap或getValue方法,這兩個在上面的內容中已經介紹過,然后uniforms[i].set(),實現最終向WebGL的傳值。這里我保留了Cesium的注釋,里面是一個很有意思的性能調優,不妨自己看看。
總結
終於寫完了,有一種如釋重負的感覺。ShaderProgram本身並不復雜,但本身是一個面向過程的方式,Cesium為了達到面向狀態的目的做了大量的封裝,在使用中更容易理解和維護。本文主要介紹這種封裝的思路和技巧,對我而言,這個過程中還是很有收獲,也加深了我對Shader的理解。我一直擔心很多人可能看完后似懂非懂,確實知識點很多,而且之間的聯系也很緊密,關鍵是需要對WebGL在這一塊的內容需要有一個扎實的認識,才能較好的解讀這層封裝的意義。我盡量說的詳細一些,但精力和能力有限,我自認為對這一塊了解已經很清晰了,但也不敢打包票。所以,如果真的想要了解,還是需要親自調試代碼,親自查探一下本文中提到的相關代碼部分。
另外,個人認為shadersource在combine函數中還是很消耗計算的,如果執行ShaderProgram.fromCache都會執行此函數兩遍(頂點和片元),所以這也是一個性能隱患處,比如GlobeSurfaceTile,如果每一個Tile都從ShaderCache中獲取對應的ShaderProgram,盡管ShaderProgram只會創建一次,但每一次在Cache中通過Key查找Value的過程中,構建Key的代價也是很大的,這也是為什么Cesium有提供了GlobeSurfaceShaderSet來的原因所在(之一)。
最后要提醒一下,本文主要提供的是一個大概的流程,對於一些特殊情況並未涉及,比如GlobeSurfaceTile中,有可能出現多影像圖層疊加的情況,也就是多重紋理,但N不固定的情況,GlobeSurfaceShaderSet.prototype.getShaderProgram中對這種情況進行了特殊處理。