如果把地球比做一個人,地形就相當於這個人的骨骼,而影像就相當於這個人的外表了。之前的幾個系列,我們全面的介紹了Cesium的地形內容,詳見:
- Cesium原理篇:1最長的一幀之渲染調度
- Cesium原理篇:2最長的一幀之網格划分
- Cesium原理篇:3最長的一幀之地形(1)
- Cesium原理篇:3最長的一幀之地形(2:高度圖)
- Cesium原理篇:3最長的一幀之地形(3:STK)
- Cesium原理篇:3最長的一幀之地形(4:重采樣)
有了前面的“骨骼”,下面我們詳細介紹一下影像篇的調度,以及最終如何結合地形的數據完成渲染的過程。
類關系概述
和TerrainProvider的類關系相似,ImageryProvider的創建也是從Globe類開始的。不過,在Cesium中,一個Globe只有一個TerrainProvider,而可以有多個ImageryProvider,比如Bing的, 天地圖的,還有文字注記的,甚至在加上局部范圍,自定義的Provider,在實際中,這種使用場景是很常見的,就想一個人,只有一副骨架,但可以搭配多件衣服一個道理。因此,在Globe中提供了ImageryLayerCollection成員,用來管理多個ImageryProvider。
對於ImageryProvider,Cesium還做了一層封裝,通過ImageryLayer來封裝不同的Provider,Provider用來負責切片數據的下載,工作的成果則通過ImageryLayer來管理,比如計算需要的瓦片數據,發送切片請求,判斷是否在緩存中已經有了Imagery(切片數據),對數據進行動態投影的換算,切片數據創建對應紋理等,都是ImageryLayer來完成的。
最后就落到了Imagery,每一個瓦片對應一個Imagery,自己把自己的事情做好(動態投影,創建紋理),維護好自身的狀態,不給組織添麻煩。
綜上所述,大概的類關系如下:
創建Imagery
有了上面的初始化過程后,我們開始討論地球網格調度的過程,Cesium是以地形Tile為標准來調度的。針對每一個地形Tile,提供prepareNewTile方法來創建地形和影像的Tile,地形的我們之前在《Cesium原理篇:3最長的一幀之地形(1) 》已經詳細討論過了,如下是影像部分的代碼:
// 請求地球網格 function prepareNewTile(tile, terrainProvider, imageryLayerCollection) { // 地形部分呢代碼…… // 遍歷imageryLayerCollection中對應的ImageryProvider for (var i = 0, len = imageryLayerCollection.length; i < len; ++i) { var layer = imageryLayerCollection.get(i); if (layer.show) { // 通過Provider·創建對應Tile的Imagery layer._createTileImagerySkeletons(tile, terrainProvider); } } }
這里就有一個問題,也就是地形的坐標系和影像坐標系可能不一致的情況。之前我們提到過,地形數據一般都是WGS84,而基本上,所有在線數據都是墨卡托投影。這樣,地形的Tile(XYZ)和影像的Tile(XYZ)就不是一一對應的關系了。而_createTileImagerySkeletons函數就是來計算這個映射關系,確定每一個地形的tile所對應哪些Imagery Tile。如果地形和影像的坐標系是一致的,那地形和影像Tile是1:1的對應關系,如果兩者不一致,則需要額外處理了。偽代碼邏輯如下:
ImageryLayer.prototype._createTileImagerySkeletons = function(tile, terrainProvider, insertionPoint) { // 獲取當前地形Tile的有效的經緯度范圍 var rectangle = Rectangle.intersection(tile.rectangle, imageryBounds, tileImageryBoundsScratch); // 獲取該影像服務的投影坐標,WGS84 or Mercator var imageryTilingScheme = imageryProvider.tilingScheme; // 計算地形Tile有效范圍的西北(左上角) 對應影像的XY序號 var northwestTileCoordinates = imageryTilingScheme.positionToTileXY(Rectangle.northwest(rectangle), imageryLevel); // 計算地形Tile有效范圍的東南(右下角) 對應影像的XY序號 var southeastTileCoordinates = imageryTilingScheme.positionToTileXY(Rectangle.southeast(rectangle), imageryLevel); // 通過兩個for循環,遍歷TileCoordinates,也就獲取到該地形Tile所需要的影像切片了 for ( var i = northwestTileCoordinates.x; i <= southeastTileCoordinates.x; i++) { for ( var j = northwestTileCoordinates.y; j <= southeastTileCoordinates.y; j++) { // 判斷該影像切片是否已經創建了 // 因為有可能出現相鄰兩個地形的Tile,一個需要影像切片的上半部分,一個需要下半部分 var imagery = this.getImageryFromCache(i, j, imageryLevel, imageryRectangle); // 引用計數,將需要的imagery綁定到對應的GlobeSurfaceTile上 surfaceTile.imagery.splice(insertionPoint, 0, new TileImagery(imagery, texCoordsRectangle)); } } }
這樣,我們就獲取了需要的影像切片,接着就是下載,創建紋理,糾偏,足夠幸運的話,最終會渲染到屏幕上,這個邏輯的代碼實現如下:
// TileImagery調用Imagery實現影像切片的相關調度 TileImagery.prototype.processStateMachine = function(tile, frameState) { var loadingImagery = this.loadingImagery; loadingImagery.processStateMachine(frameState); } // 基於狀態的影像數據調度 Imagery.prototype.processStateMachine = function(frameState) { // 如果該影像切片沒有下載,則下載 if (this.state === ImageryState.UNLOADED) { this.state = ImageryState.TRANSITIONING; this.imageryLayer._requestImagery(this); } // 下載后創建對應的紋理 if (this.state === ImageryState.RECEIVED) { this.state = ImageryState.TRANSITIONING; this.imageryLayer._createTexture(frameState.context, this); } // 進行投影換算,糾偏 if (this.state === ImageryState.TEXTURE_LOADED) { this.state = ImageryState.TRANSITIONING; this.imageryLayer._reprojectTexture(frameState, this); } };
ReprojectTexture
這里代碼都比較容易理解,着重講一下這個投影轉換的過程,先看如下兩個圖:
前者是WSG84,后者是墨卡托下對應地球全幅的效果,可見前者長寬比是2:1,而后者是1:1.因此,總體來說,如果對兩者做四叉樹剖分,前者需要先豎直切兩半(X方向),剩下的都一樣(Y方向)。這樣,動態投影的過程可以粗略的認為就是把下面這張圖拉伸成上面這個圖的過程。
如果大家對動態投影有一定了解的話,應該知道這個過程的計算量是很大的,而我們畢竟是JS的應用,對此Cesium采用了兩個策略,一是簡化數據,將這個256*256簡化為2*64大小,類似掃描行來矯正,二是通過Shader,通過GPU RTT的方式,從硬件上來實現高效轉換。具體的實現函數是reprojectToGeographic,Cesium做了很詳細的解釋,為何最終選擇這種方式,比如對移動平台的考慮等,有興趣的可以看一下源碼,這里僅給出最終position和紋理uv的計算過程,最終在shader中就是將圖片當前position對應的位置,賦予紋理中對應uv的像素值。
// position var positions = new Float32Array(2 * 64 * 2); var index = 0; for (var j = 0; j < 64; ++j) { var y = j / 63.0; positions[index++] = 0.0; positions[index++] = y; positions[index++] = 1.0; positions[index++] = y; } // 經緯度下對應的uv值 for (var webMercatorTIndex = 0; webMercatorTIndex < 64; ++webMercatorTIndex) { var fraction = webMercatorTIndex / 63.0; var latitude = CesiumMath.lerp(south, north, fraction); sinLatitude = Math.sin(latitude); var mercatorY = 0.5 * Math.log((1.0 + sinLatitude) / (1.0 - sinLatitude)); var mercatorFraction = (mercatorY - southMercatorY) * oneOverMercatorHeight; webMercatorT[outputIndex++] = mercatorFraction; webMercatorT[outputIndex++] = mercatorFraction; }
換句話說,通過上面的轉換算法,對關鍵點構成三角網,其他的點在片元中插值,這樣生成一張新的紋理(RTT),將經過坐標系轉換的紋理替換之前原始的墨卡托紋理。這里回答之前的一個情況:如果地形也是采用Mercator(只有默認的EllipsoidTerrainProvider可以選擇這種坐標系),影像也是Mercator,這樣就不需要投影轉換,性能上應該會更好吧。理論上確實如此,但實際上,通過代碼,Cesium並沒有考慮過這種情況,所以只要判斷影像不是WGS84的,統一都做了一次轉換。換個角度來說,我發現即使不做投影轉換,肉眼看上去,效果上並沒有什么差別。
DrawCommandsForTile
講到這,終於到了這一幀的最后時刻,歷盡千辛萬苦,百般阻撓,強壯了我的骨骼,滋潤了我的肌膚后,終於進入了渲染環節。
Cesium的渲染都是通過DrawCommand來完成,這一塊的理解需要對Render模塊有一個認識,所以這里也不打算展開講。簡單的說,主要是VertexArray來綁定VBO(地形數據),通過uniformMap來傳遞頂點和片元着色器的參數,而通過dayTextures將該Tile對應的多個影響紋理傳入到Shader中。下面,主要介紹一下多個紋理疊加和水面的實現。
多重紋理
為了考慮多重紋理的可能,Cesium在GlobeSurfaceShaderSet.prototype.getShaderProgram中用一個笨方法來處理:
var computeDayColor = '\ vec4 computeDayColor(vec4 initialColor, vec2 textureCoordinates)\n\ {\n\ vec4 color = initialColor;\n'; for (var i = 0; i < numberOfDayTextures; ++i) { computeDayColor += '\ color = sampleAndBlend(\n\ color,\n\ u_dayTextures[' + i + '],\n\ textureCoordinates,\n\ u_dayTextureTexCoordsRectangle[' + i + '],\n\ u_dayTextureTranslationAndScale[' + i + '],\n\ ' + (applyAlpha ? 'u_dayTextureAlpha[' + i + ']' : '1.0') + ',\n\ ' + (applyBrightness ? 'u_dayTextureBrightness[' + i + ']' : '0.0') + ',\n\ ' + (applyContrast ? 'u_dayTextureContrast[' + i + ']' : '0.0') + ',\n\ ' + (applyHue ? 'u_dayTextureHue[' + i + ']' : '0.0') + ',\n\ ' + (applySaturation ? 'u_dayTextureSaturation[' + i + ']' : '0.0') + ',\n\ ' + (applyGamma ? 'u_dayTextureOneOverGamma[' + i + ']' : '0.0') + '\n\ );\n'; } computeDayColor += '\ return color;\n\ }';
半自動植入計算computeDayColor的方法,其中,sampleAndBlend是shader中自帶的函數,通過這些參數來獲取紋理對應位置的顏色,而computeDayColor本身就是一個for循環,實現該位置下多個顏色的疊加,這樣做的好處是里面的參數很多,而且不是定長的,所以避開了傳參的麻煩。只要了解了這個過程,我們在看GlobeFS.glsl就簡單多了:
vec4 color = computeDayColor(u_initialColor, clamp(v_textureCoordinates, 0.0, 1.0));
輕松一句話,實現了多重紋理疊加,處理起來也方便很多,看來笨也有笨的智慧。當然,這里還有一個紋理糾偏的處理。有可能一個地形切片各占兩個影像切片的一部分,這樣,紋理對應地形切片的起始點就會有一個偏移和縮放的處理,保質兩者匹配吻合。
ImageryLayer.prototype._calculateTextureTranslationAndScale = function(tile, tileImagery) { var imageryRectangle = tileImagery.readyImagery.rectangle; var terrainRectangle = tile.rectangle; var terrainWidth = terrainRectangle.width; var terrainHeight = terrainRectangle.height; var scaleX = terrainWidth / imageryRectangle.width; var scaleY = terrainHeight / imageryRectangle.height; // xy為偏移,zw為縮放 return new Cartesian4( scaleX * (terrainRectangle.west - imageryRectangle.west) / terrainWidth, scaleY * (terrainRectangle.south - imageryRectangle.south) / terrainHeight, scaleX, scaleY); }; // 片元中紋理計算公式 vec2 textureCoordinates = tileTextureCoordinates * scale + translation;
水面
坦白說,這塊我也是一知半解,里面有兩個關鍵的參數,waterMask和oceanNormalMap,時間是根據czm_frameNumber來模擬的。坦白說,這部分代碼的物理原理我還真不清楚,最終就是各類反射光的疊加,不多說廢話了,等以后有機會再說吧。該方法可參考:
vec4 computeWaterColor(vec3 positionEyeCoordinates, vec2 textureCoordinates, mat3 enuToEye, vec4 imageryColor, float maskValue)
至此,最長的一幀之Cesium告一段落,個人盡力詳細介紹了Cesium整個球在渲染過程中的相關細節,希望對大家會有所收獲。后面,會繼續在應用,原理上繼續深入的學習,研究和分享關於Cesium的個人見解。