地形部分的原理介紹的差不多了,但之前還有一個刻意忽略的地方,就是地形的重采樣。通俗的講,如果當前Tile沒有地形數據的話,則會從他父類的地形數據中取它所對應的四分之一的地形數據。打個比方,當我們快速縮放影像的時候,下一級的影像還沒來得及更新,所以會暫時把當前Level的影像數據放大顯示, 一旦對應的影像數據下載到當前客戶端后再更新成精細的數據。Cesium中對地形也采用了這樣的思路。下面我們具體介紹其中的詳細內容。
上圖是一個大概流程,在創建Tile的時候(prepareNewTile),第一時間會獲取該Tile父節點的地形數據(upsampleTileDetails),然后構造出upsampledTerrain對象,它是TileTerrain對象,只是一個包含父類地形信息的空殼。接着,開始創建地形網格(processTerrainStateMachine)。
這里就有兩個邏輯,如果當前沒有地形數據,也就是EllipsoidTerrainProvider的情況,這樣會直接創建HeightmapTerrainData。因此狀態是TerrainState.RECEIVED,這種情況下不需要重采樣;如果請求了真實的地形數據,比如CesiumTerrainProvider,無論是請求高度圖還是STK,只要有異步請求,則會執行processUpsampleStateMachine韓式,最終實現重采樣(sourceData.upsample)。
HeightmapTerrainData.prototype.upsample
我們先了解一下高度圖下的實現。高度圖,顧名思義也是一種圖了,所以這個重采樣的方式和普通的圖片拉伸算法一致。比如一個2*2的圖片,放大至4*4的大小,這里就有一個插值的過程。比如線性差值,會取相鄰的兩個像素顏色,加權求值,或者雙線性插值,取周邊四個像素,加權求值。這讓我想到了GDI中對圖片是采用線性了,而Photoshop里面則有很多專業的選項,某些逗逼用戶經常拿着PS拉伸的效果來做對比,說我們圖片拉伸的效果不如PS。等我們做完了,又拿CorelDraw來對比矢量效果。但你有很難從技術和產品的角度來和用戶溝通其中的利弊。這是題外話了,我們來看一下Cesium具體的代碼:
for (var j = 0; j < height; ++j) { var latitude = CesiumMath.lerp(destinationRectangle.north, destinationRectangle.south, j / (height - 1)); for (var i = 0; i < width; ++i) { var longitude = CesiumMath.lerp(destinationRectangle.west, destinationRectangle.east, i / (width - 1)); var heightSample = interpolateMeshHeight(buffer, encoding, heightOffset, heightScale, skirtHeight, sourceRectangle, width, height, longitude, latitude, exaggeration); setHeight(heights, elementsPerHeight, elementMultiplier, divisor, stride, isBigEndian, j * width + i, heightSample); } }
這是兩個for循環,遍歷目標圖片中每一個經緯度對應父類圖片中該位置的高度值。下面是一個切片四叉樹的示意圖,高度圖也是一個思路,只是其中每一個像素不是顏色,而是高度值:
如同可見,子類切片的像素大小和父類是一樣的,一般都是256*256的切片,但具體到地理范圍上則只有父類的四分之一,所以顧名思義是四叉樹。這樣,從父類到子類放大的過程中,父類的一個像素,在子類中占了4個像素。也就是一個1:4的映射關系。盡管像素都是整數的,但我們在插值的過程中會有一個亞像素的概念。這樣,同一個位置,經緯度都是相同的,但在子類和父類中的uv是不一樣的,在對子類的遍歷中,獲取同一個經緯度對應父類uv的位置,進而得知在父類中相鄰的四個像素和權重,進而插值獲取其高度(顏色),如下是一個示意代碼:
function interpolateMeshHeight(){ var fromWest = (longitude - sourceRectangle.west) * (width - 1) / (sourceRectangle.east - sourceRectangle.west); var fromSouth = (latitude - sourceRectangle.south) * (height - 1) / (sourceRectangle.north - sourceRectangle.south); var widthEdge = (skirtHeight > 0) ? width - 1 : width; var westInteger = fromWest | 0; var eastInteger = westInteger + 1; if (eastInteger >= widthEdge) { eastInteger = width - 1; westInteger = width - 2; } var dx = fromWest - westInteger; return southwestHeight + (dX * (northeastHeight - northwestHeight)) + (dY * (northwestHeight - southwestHeight)) }
QuantizedMeshTerrainData.prototype.upsample
高度圖畢竟還都是離散的點值,並沒有構網,因而節點之間還沒有建立關聯,差值算法也相對容易一些。而STK的數據,本身已經是TIN的三角網結構了。這時,在父類中切割出四分之一來就有點復雜了。再打個比方,如果高度圖相當於一個棋盤上均勻的大米,然后你四等分,取走其中的一份,而TIN三角網則相當於一個錯綜復雜的下水管,你要切走四分之一。這要怎么做到呢。假設此時我們有一把利刃,把這個TIN網格橫一刀豎一刀,這時,我們迅速的把漏水的管道密封(形成新的節點),這樣就實現了TIN三角網重采樣的過程。
當然,這個過程相比高度圖要復雜的多,因此Cesium中創建了Worker線程,切割的過程都是在線程中完成。具體到算法則如下:
for (i = 0; i < parentIndices.length; i += 3) { var i0 = parentIndices[i]; var i1 = parentIndices[i + 1]; var i2 = parentIndices[i + 2]; var u0 = parentUBuffer[i0]; var u1 = parentUBuffer[i1]; var u2 = parentUBuffer[i2]; triangleVertices[0].initializeIndexed(parentUBuffer, parentVBuffer, parentHeightBuffer, parentNormalBuffer, i0); triangleVertices[1].initializeIndexed(parentUBuffer, parentVBuffer, parentHeightBuffer, parentNormalBuffer, i1); triangleVertices[2].initializeIndexed(parentUBuffer, parentVBuffer, parentHeightBuffer, parentNormalBuffer, i2); // Clip triangle on the east-west boundary. var clipped = Intersections2D.clipTriangleAtAxisAlignedThreshold(halfMaxShort, isEastChild, u0, u1, u2, clipScratch); // Get the first clipped triangle, if any. clippedIndex = 0; if (clippedIndex >= clipped.length) { continue; } clippedIndex = clippedTriangleVertices[0].initializeFromClipResult(clipped, clippedIndex, triangleVertices); if (clippedIndex >= clipped.length) { continue; } clippedIndex = clippedTriangleVertices[1].initializeFromClipResult(clipped, clippedIndex, triangleVertices); if (clippedIndex >= clipped.length) { continue; } clippedIndex = clippedTriangleVertices[2].initializeFromClipResult(clipped, clippedIndex, triangleVertices); // Clip the triangle against the North-south boundary. clipped2 = Intersections2D.clipTriangleAtAxisAlignedThreshold(halfMaxShort, isNorthChild, clippedTriangleVertices[0].getV(), clippedTriangleVertices[1].getV(), clippedTriangleVertices[2].getV(), clipScratch2); if(clipped2.length == 10 && clipped.length == 10) var i = 10; addClippedPolygon(uBuffer, vBuffer, heightBuffer, normalBuffer, indices, vertexMap, clipped2, clippedTriangleVertices, hasVertexNormals); // If there's another vertex in the original clipped result, // it forms a second triangle. Clip it as well. if (clippedIndex < clipped.length) { clippedTriangleVertices[2].clone(clippedTriangleVertices[1]); clippedTriangleVertices[2].initializeFromClipResult(clipped, clippedIndex, triangleVertices); clipped2 = Intersections2D.clipTriangleAtAxisAlignedThreshold(halfMaxShort, isNorthChild, clippedTriangleVertices[0].getV(), clippedTriangleVertices[1].getV(), clippedTriangleVertices[2].getV(), clipScratch2); addClippedPolygon(uBuffer, vBuffer, heightBuffer, normalBuffer, indices, vertexMap, clipped2, clippedTriangleVertices, hasVertexNormals); } }
這個算法有點復雜,但思路清楚了,也就迎刃而解。首先,遍歷頂點索引,每次+3,因為三個點構成一個三角形,所以完成了對所有三角網遍歷切割的過程。在每次循環中,u0,u1,u2是三角形對應的三個點,然后通過Intersections2D.clipTriangleAtAxisAlignedThreshold實現三角形的切割算法。
這個切割的過程其實就是三角形和直線求交的過程,但更直觀一些,因為這個直線是豎直或水平的,如果直線和三角形沒有交點,那表示該三角形要么全在子類,要么全不在,不需要切割,我們不討論這種情況。如果相交,則有兩種情況:
第一種情況,只保留了原三角形一個頂點,但會產生兩個新的節點(藍色),最終形成一個三角形。針對這種情況,我們在做一次水平的切合(水平線和藍色三角形的相交計算),這個算法是一致的。
第二種情況,保留了原三角形兩個頂點,同時也產生了兩個新的節點(必然是偶數節點,哥尼斯堡七橋問題),這時,會形成兩個三角形,則我們需要對這兩個三角形單獨做一次水平切割。
經過如上的邏輯,我們就完成了一個三角形的兩刀切,當然,算法只提供了一個思路,並沒有考慮特殊情況,正好經過頂點對半切,這個在實際中需要做一次額外的判斷,避免少算或重復算。細的說里面有兩個過程,求交點,構造新的三角形,分別通過clipTriangleAtAxisAlignedThreshold和addClippedPolygon函數實現。這時,如是是第二種情況,則還有一個三角形需要進行水平的切割和構造新三角形的過程。這是為什么會多一個if判斷。
如上,新的,經過重采樣的TIN三角網構建完成,Cesium會先渲染這個略微粗糙的地形,等待精細的地形下載完后在更新。當然,通過這個過程,我們能意識到,Cesium並不硬性的要求每一個地形Tile都能夠獲取到,如果其中一個Tile沒有下載到(網絡異常或環境限制),也能很好的自適應,而且也不方案該Tile的子類也可以正常渲染和更新。但前提是,根節點的地形數據是必須的。不管是蛋生雞還是雞生蛋,你總得現有一樣。