
前陣子接了個活兒在 Cesium 三維地球里展示一塊真實地形。需求看著不復雜但有一個硬條件——地形數(shù)據(jù)不能走公網(wǎng)必須本地生成、本地托底。這就意味著不能靠 Cesium 內(nèi)置的在線地形也不能直接丟到云端平臺一傳了之。我花了兩天把整條鏈路跑通從 DEM數(shù)字高程模型裸數(shù)據(jù)到切出來能被 CesiumTerrainProvider 直接消費的地形瓦片中間踩了不少坑。這篇就完整記一下我的處理過程、參數(shù)選擇和避坑方法給后面要自建地形數(shù)據(jù)的同學一個可復制的參考。適合有 Cesium 基礎但沒碰過地形生成的人也適合要離線化部署三維地圖的項目組。1. 先想清楚技術鏈路自建地形數(shù)據(jù)到底在解決什么問題1.1 Cesium 地形渲染的本質(zhì)流式 LOD 瓦片很多人第一次用 Cesium 就發(fā)現(xiàn)new Cesium.Viewer(...)之后場景里默認帶了一塊橢球表面但是上面沒有山川起伏。Cesium 的“地形”本質(zhì)不是貼圖而是一張精細的三角網(wǎng)高度場。它按照四叉樹結構把全球劃分成密密麻麻的瓦片每個瓦片又是一個小網(wǎng)格網(wǎng)格頂點上帶有高程值。瀏覽器拿到這些頂點坐標和高程再結合法線、光照和相機視距才渲染出能看到的起伏地表。這里最關鍵的一點是Cesium 的地形瓦片是流式加載的相機離哪里近哪里就加載更精細的子瓦片離遠了就統(tǒng)一合并成粗糙的父瓦片。這種 LOD細節(jié)層次機制決定了地形文件的組織方式必須嚴格遵循固定的目錄結構和元數(shù)據(jù)約定。Cesium 支持的離線地形格式主要有兩種一種叫 Heightmap高度圖一種叫 Quantized-Mesh量化網(wǎng)格后者是當前主流后面我會展開說。很多人會把“地形瓦片”和“影像瓦片”混為一談。影像瓦片是一張張 256x256 的 JPEG/PNG 圖片而地形瓦片是包含頂點坐標、高程索引、法線信息甚至水紋信息的二進制文件。這個區(qū)別直接決定了自建地形的處理方式和存儲結構千萬別用做影像切片的心態(tài)去套。1.2 為什么必須自建地形數(shù)據(jù)如果只是在公網(wǎng)演示直接用默認地形當然最簡單。但我遇到的場景里項目必須斷網(wǎng)運行地形數(shù)據(jù)不能經(jīng)過第三方服務器。即使允許聯(lián)網(wǎng)默認地形在部分網(wǎng)絡環(huán)境的加載速度也相當不穩(wěn)定用戶的真實體驗是“影像都加載出來了地形還是白板”。還有一類需求更直接客戶手里有一份自己采集的高精度 DEM比如 2 米、5 米的無人機航測數(shù)據(jù)默認地形分辨率太低根本沒法拿來展現(xiàn)真實地貌。這時唯一靠譜的方式就是自己把 DEM 切成 Cesium 需要的地形瓦片再部署到內(nèi)網(wǎng)靜態(tài)服務器上。自建地形還有另一個隱性收益可以對業(yè)務范圍做裁剪和加密只發(fā)布項目涉及的區(qū)域。比如只切某片山區(qū)范圍數(shù)據(jù)量會從全球級縮到十幾萬個瓦片以內(nèi)本地加載性能反而比默認全球地形快得多。更重要的是離線環(huán)境下所有地形數(shù)據(jù)都在自己手里版本、精度、更新時間都可控不會因為第三方服務變化而影響項目交付。1.3 從 DEM 到 Terrain 瓦片的一條主鏈路整條鏈路捋清楚其實就四步搞到原始高程數(shù)據(jù)DEM一般是一個 GeoTIFF 文件用 GDAL 做格式、坐標系、范圍的預處理用地形切片工具把 DEM 切成 quantized-mesh 瓦片形成layer.json{z}/{x}/{y}.terrain的目錄結構把瓦片目錄掛到任意靜態(tài)服務器上在 Cesium 里配置CesiumTerrainProvider指向它??粗欢嗟恳徊蕉加袔讉€容易翻車的細節(jié)。下面我把我實操里最影響結果的參數(shù)和判斷寫清楚避免大家后面走彎路。2. 關鍵概念與方案選型動手之前先避開這幾個坑2.1 第一手材料DEM 數(shù)據(jù)的選擇與檢查地形數(shù)據(jù)生成的地基就是 DEM。DEM 本身是一個柵格每個像素記錄一個地面海拔值。不同來源的 DEM 分辨率差異很大直接影響最終地形的精細度。我這次用的是常見的 30 米分辨率開源高程模型肉眼看一下山體形態(tài)完全夠用如果項目要展示更精細的沖溝、田坎、道路斷面就得用 5 米甚至更高精度的航測 DEM。選數(shù)據(jù)源時我建議先確認四件事下面這個表格就是我的檢查清單數(shù)據(jù)屬性檢查標準對切片結果的影響坐標系是 WGS84 經(jīng)緯度還是投影坐標系Cesium 地形瓦片需要 WGS84 地理坐標投影坐標必須重投影高程基準海拔高還是橢球高相差一個大地水準面起伏山區(qū)可能差幾十米像元位深16 位整型或 Float328 位只有 256 級高度山區(qū)會被量化成臺階無效值NoData是否有明確的 NoData 標記不處理會在區(qū)域邊界生成深坑或黑洞我這次的數(shù)據(jù)源是客戶提供的一塊 GeoTIFF范圍大概覆蓋一片 20km×20km 的山區(qū)。拿到手第一件事就是跑gdalinfo先看它的坐標系、像素尺寸、波段類型和無效值。這一步?jīng)]做扎實后面切出來全是問題。提示如果 DEM 的無效值沒有被正確標記切片工具會把無效像素當普通高程參與插值最終在數(shù)據(jù)邊緣產(chǎn)生一個向下的漏斗形凹坑。這個坑在切完瓦片后很難從目錄結構上發(fā)現(xiàn)只有加載到 Cesium 里才能看到非常隱蔽。2.2 理解瓦片格式為什么 Quantized-Mesh 是主線Cesium 在早期版本里大量使用 heightmap 地形它本質(zhì)上是每瓦片固定尺寸的高度數(shù)組生成簡單但有兩個短板一是頂點位置完全按等間隔排布無法根據(jù)地形起伏智能細化網(wǎng)格二是每片瓦片的 LOD 切換全靠客戶端容易在陡峭區(qū)域露出明顯的鋸齒。Quantized-Mesh 是 Cesium 后來主推的格式每個瓦片是一個真正的不規(guī)則三角網(wǎng)頂點坐標經(jīng)過量化高度和法線信息都打包在.terrain文件里加載時直接按索引渲染。用起來最直觀的差別是quantized-mesh 地形在陡坡和溝壑處線條更貼合真實地貌同一級別下文件體積也更小。而且它原生支持requestVertexNormals和requestWaterMask這意味著你可以給地形加真實的斜坡光照效果還能在水體部分生成水面漣漪。所以我的建議很直接新項目無腦選 quantized-mesh除非你要兼容很老的 Cesium 版本或者只想快速出個粗糙預覽。還有一個細節(jié)值得注意Quantized-Mesh 瓦片的頂點數(shù)通常是 65x65而不是影像瓦片那種 256x256。也就是說每個地形瓦片在屏幕上最多渲染 4225 個網(wǎng)格頂點再配合 LOD 層級形成一個兼顧渲染效率和細節(jié)的三角網(wǎng)。這個 65 就是切片工具里常見的--tile-size參數(shù)。2.3 工具鏈與坐標系動手前必須認的兩件事生成 quantized-mesh 瓦片常見路線有兩種一種是把 DEM 上傳到云平臺讓它幫你切好瓦片再配置 token 訪問另一種是用開源工具在本地切。我這個項目因為必須離線直接走了本地開源工具。工具選型時主要看三點是否支持 GeoTIFF 直接輸入、切出來的瓦片是否帶layer.json、是否支持指定最大層級和采樣范圍。大多數(shù)本地切片工具都能滿足關鍵是要先驗證輸出格式是不是 Cesium 的 quantized-mesh而不是通用 3D Tiles。坐標系這塊是最容易出錯的環(huán)節(jié)。Cesium 的地形瓦片默認使用 WGS84 經(jīng)緯度坐標而這個坐標系下經(jīng)緯度是角度不是米。如果你的原始 DEM 是 UTM 投影或者高斯投影里面每個像素的寬高是米必須先用gdalwarp重投影成 EPSG:4326。重投影之后柵格像素不再是規(guī)則的方形米格而是規(guī)則的角度格Cesium 切片的 LOD 才符合預期。3. 實操記錄從 DEM 到 Cesium 地形的完整過程3.1 動手第一步gdalinfo 檢查數(shù)據(jù)底子我拿到 GeoTIFF 后先跑了一條命令gdalinfo input_dem.tif看幾個關鍵字段Size is表示寬高像元數(shù)Coordinate System is顯示坐標系Pixel Size表示地面分辨率Band 1下的Type表示像元類型NoData Value表示無效值。我這次的數(shù)據(jù)是 WGS84 經(jīng)緯度坐標30 米分辨率16 位整型。如果你看到的是 UTM 投影或者沒有 NoData 值后續(xù)就必須先處理。另一個要確認的是數(shù)據(jù)范圍和業(yè)務范圍是否匹配。我拿到數(shù)據(jù)時范圍比業(yè)務區(qū)大了一圈而且周邊是無效的黑色區(qū)域。Cesium 地形切片工具遇到邊緣無效值可能生成異常地形所以動手前我先把 DEM 裁剪到實際需要的地塊。這一步其實還減少了后面瓦片的數(shù)量屬于一舉兩得。如果有人給你的是多塊碎 DEM千萬別直接切片。多塊 DEM 之間可能存在重疊、縫隙和高程不一致切出來的瓦片接縫處會非常難看。我的習慣是先看每塊數(shù)據(jù)的gdalinfo確認四至范圍和坐標系完全一致再決定要不要合并。3.2 預處理重投影、合并與裁剪如果 DEM 坐標系不是 WGS84先重投影。我用了雙線性插值因為地形是連續(xù)表面這種插值不會像最近鄰那樣產(chǎn)生生硬的臺階又比三次卷積速度快gdalwarp -t_srs EPSG:4326 -r bilinear -of GTiff input_dem.tif wgs84_dem.tif如果業(yè)務區(qū)跨多塊 DEM先用gdal_merge.py合并python3 gdal_merge.py -o merged_dem.tif -init 0 -n 0 tile1.tif tile2.tif tile3.tif參數(shù)-n 0表示把 0 當作 NoData這樣可以排除無效區(qū)-init 0指定背景值。合并完再按目標范圍裁剪gdalwarp -te 12.00 56.00 12.20 56.20 -tr 0.00027777778 0.00027777778 -r bilinear -of GTiff merged_dem.tif clip_dem.tif-te后面依次是最小經(jīng)度、最小緯度、最大經(jīng)度、最大緯度-tr表示目標分辨率30 米換算成度大概是1/3600也就是0.0002777778。這樣輸出柵格的每像素就是規(guī)整的 30 米采樣后面切片工具計算層級時會很清爽。預處理做完再用gdalinfo clip_dem.tif看一遍確認范圍、分辨率、NoData 都符合預期。我踩過的坑是裁剪之后忘記確認 NoData 值切出來的瓦片在區(qū)域邊界出現(xiàn)一大塊凹陷的黑洞。后來我在切片參數(shù)里顯式聲明 NoData 值才解決。3.3 切片生成 quantized-mesh 瓦片預處理完成后進入核心環(huán)節(jié)。我這里用的工具命令大致如下terrain-tiler \ --input clip_dem.tif \ --output ./cesium_terrain \ --max-level 15 \ --format cesium \ --tile-size 65 \ --no-data -32768不同工具的參數(shù)名有差異但核心邏輯一致讀入 GeoTIFF從 level 0 開始逐級切分每個瓦片固定為 65x65 個頂點生成layer.json和一堆.terrain文件。--max-level 15并不是隨便拍的我估算了這個級別下的瓦片分辨率已經(jīng)接近原始 DEM 的分辨率再往上切不會產(chǎn)生更多真實細節(jié)只會白白增加瓦片數(shù)量。最大層級可以按這個思路估算Cesium 地形瓦片第z層全球大約有2^z個瓦片一塊瓦片對應地面的尺寸大約是地球周長除以2^z。當這個尺寸接近原始 DEM 的兩倍像素大小時說明這個層級已經(jīng)能還原出 DEM 的細節(jié)再往下走就屬于重復勞動。30 米分辨率 DEM 我通常給max-level 14到15如果是航測的 1 米分辨率max-level 17起也正常。有些切片工具會默認給瓦片加 skirt裙邊也就是在瓦片邊緣向下延伸一小圈三角網(wǎng)用來避免不同層級瓦片拼接時出現(xiàn)裂縫。如果你的工具默認沒有記得手動開啟這是保證 LOD 切換平滑的關鍵。生成完檢查一下目錄find ./cesium_terrain -type f | wc -l我這次切出來大概 8 萬個.terrain文件和一個layer.json。文件數(shù)量有點多但不影響靜態(tài)部署Nginx 處理靜態(tài)小文件的能力足夠。3.4 接入環(huán)節(jié)本地起服務并配置 CesiumTerrainProvider瓦片目錄本質(zhì)上只要求靜態(tài)文件服務不需要后端處理。我在本地直接用 Python 起了一個靜態(tài)服務cd cesium_terrain python3 -m http.server 8080然后寫一個最簡單的 Cesium 頁面。如果你用的是老版本 API是這樣配置的const viewer new Cesium.Viewer(cesiumContainer, { terrainProvider: new Cesium.CesiumTerrainProvider({ url: http://localhost:8080, requestVertexNormals: true, requestWaterMask: false }) });如果你用的是新版 Cesium更推薦異步方式const terrain await Cesium.Terrain.fromUrl(http://localhost:8080, { requestVertexNormals: true }); const viewer new Cesium.Viewer(cesiumContainer, { terrain });把相機飛到業(yè)務區(qū)驗證地形是否生效viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(12.10, 56.10, 12000), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 } });如果頁面加載后能看到明顯的山體起伏和坡度陰影說明瓦片格式和通信鏈路都通了。這里有個常見誤區(qū)URL 不要拼到某個具體瓦片文件只要指到包含layer.json的根目錄即可。CesiumTerrainProvider會自己請求layer.json然后按規(guī)則去請求{z}/{x}/{y}.terrain。3.5 部署后的性能與顯示調(diào)優(yōu)本地驗證通過后我把它遷到了內(nèi)網(wǎng)一臺 Nginx 靜態(tài)目錄上。Nginx 配置很簡單server { listen 8080; location /terrain/ { alias /data/cesium_terrain/; add_header Access-Control-Allow-Origin *; expires 1h; } }Access-Control-Allow-Origin這一行很多人會漏掉。如果你的 Cesium 頁面和地形瓦片在不同端口或不同域名瀏覽器會因為跨域直接攔掉地形請求表現(xiàn)就是地形死活不顯示但 F12 里能看到一堆 403。我這次本地http.server默認帶了 CORS 頭所以本地沒踩到部署到 Nginx 時漏了排查了一會兒才定位。性能調(diào)優(yōu)重點看三個參數(shù)maximumScreenSpaceErrorCesium 認為屏幕空間誤差小于該值時不再細分瓦片默認 2。如果地形加載慢調(diào)高到 4 到 8會明顯減少細膩瓦片的請求量。maximumLevel設置瓦片服務的最大層級防止客戶端請求不存在的層級。enableLighting開啟后地形會根據(jù)法線產(chǎn)生光影效果視覺立體感強但對性能有一點消耗。我最終把maximumScreenSpaceError設成了 4配合requestVertexNormals開啟光照觀感不錯加載速度也比默認快了一截。4. 常見問題與排查技巧實錄4.1 地形不顯示從 403 到 provider 初始化失敗最典型的癥狀頁面能顯示地球但地形區(qū)域要么是平的一大塊要么黑乎乎一片。優(yōu)先排查順序如下跨域問題看瀏覽器控制臺有沒有 CORS 報錯。有的話先在后端加響應頭。URL 配錯確認CesiumTerrainProvider的 URL 指向包含layer.json的目錄而不是某個具體的瓦片路徑。layer.json 缺失或格式錯誤切片工具沒生成或者手動改動過內(nèi)部字段Cesium 會直接拋出 Provider 初始化失敗。瓦片請求 404如果請求的級別超出了max-level或maximumLevel說明最大層級配置和實際生成層級對不上。我遇到過一次詭異情況切完瓦片后把目錄從服務器 A 拷貝到服務器 B 時layer.json和.terrain文件同級目錄變成了layer.json加上一個子文件夾串。原因是壓縮包解壓時多了一層目錄。后來我寫了個小腳本在目錄啟動時檢查路徑結構發(fā)現(xiàn)問題立刻報錯。下面是一張速查表基本覆蓋我遇到過的地形不顯示問題現(xiàn)象可能原因處理建議網(wǎng)絡請求報 403跨域頭缺失后端加Access-Control-Allow-OriginProvider 初始化失敗layer.json非法或 URL 未指向根目錄重新生成核對 URL瓦片 404最大層級配置不一致同步max-level與maximumLevel地球變白或黑屏相機位置未設到瓦片范圍飛到業(yè)務區(qū)中心或設置imageryProvider4.2 高程數(shù)值不對坐標系、高程基準與無效值這個坑最隱藏??船F(xiàn)象是山體基本變形整體被壓扁或者拔高甚至出現(xiàn)一片負數(shù)深坑。通常是兩類原因高程基準不一致或者 NoData 值被當成了真實高程。Cesium 的地形高度是相對 WGS84 橢球面的很多 DEM 的高程是相對平均海平面的。兩者之間差一個大地水準面起伏在山區(qū)可以達到十幾米甚至幾十米。這在宏觀山體上視覺看不出太大差別但你要是做水平面交匯核算就會發(fā)現(xiàn)水庫、道路的高程對不上。解決辦法是拿到數(shù)據(jù)時先問清楚高程基準如果是海拔高處理層加一個 geoid 改正 DEM或者在 Cesium 端把所有地形高度做統(tǒng)一平移但后者不夠精確。NoData 值的問題更隱蔽。如果 DEM 在岸邊和河谷區(qū)域是無效值 0切片工具可能把 0 當作海平面高度于是原本應是斷面的地方生成一個深坑。檢查辦法是在預處理后用gdalinfo -hist看高程直方圖確認無效值占比切片參數(shù)里顯式聲明 NoData就能讓工具跳過無效像素。4.3 瓦片接縫與 LOD 跳變skirt 和數(shù)據(jù)邊界的雙重排查相鄰兩個不同層級的瓦片渲染時如果邊緣頂點對不齊會出現(xiàn)一條細小的白色或者地形斷裂線。數(shù)字孿生項目里這種裂縫特別扎眼。Cesium 對 quantized-mesh 的邊緣頂點有裙邊機制在每個瓦片底部向下多延伸一小圈三角這樣不同層級瓦片拼合時高度差異會被裙邊遮擋住。如果裂縫很嚴重先看工具生成參數(shù)里有沒有啟用 skirt。有的工具參數(shù)叫--skirt-height一般給 1 到 5 米即可太大反而會造成山體邊緣懸空。另外還要檢查原始 DEM 是不是瓦片接縫處存在拼接斷層這種是數(shù)據(jù)本身的問題skirt 救不回來要用gdal_merge時做邊界羽化或者重新插值。還有一個容易被忽略的點瓦片層級的跳變不能跨級。Cesium 要求父子層級連續(xù)加載如果客戶端從 level 10 直接跳到 level 12中間 level 11 的數(shù)據(jù)缺失就會看到“閃跳”或者一片空白。檢查切片目錄里是否有完整的跨層級索引必要時重新生成。4.4 瓦片加載性能問題緩存、SSE 與靜態(tài)服務器優(yōu)化地形瓦片動輒幾萬個瀏覽器不可能全部加載進來。Cesium 本身有 LRU 緩存但默認值可能不適合你的場景。我能直接落地的小技巧限制瓦片緩存數(shù)量如果出現(xiàn)紋理內(nèi)存不足的報錯可以調(diào)整viewer.scene.context.cacheBytes或者按機器內(nèi)存反向調(diào)優(yōu)。降低屏幕空間誤差不是越低越好默認 2 已經(jīng)不錯調(diào)成 1 會瘋狂請求瓦片頁面卡成幻燈片。預置相機位置把相機初始視角設在業(yè)務區(qū)中心而不是默認全球視角避免前期加載大量無關瓦片。關閉水紋、法線插件如果場景不需要水體效果requestWaterMask: false能省掉相當一部分數(shù)據(jù)解析和 GPU 開銷。還有一點部署環(huán)境是虛擬機或低配服務器時瓦片讀取路徑的磁盤 IO 很容易到瓶頸。建議把瓦片目錄放到 SSD 上并用 Nginxexpires開啟瀏覽器緩存。實測下來瓦片請求數(shù)量可以減少 60% 以上加載時間肉眼可見縮短。5. 地形數(shù)據(jù)生成后的長期維護經(jīng)驗5.1 數(shù)據(jù)更新不改整體只刷局部自建瓦片不是一錘子買賣。如果業(yè)務區(qū) DEM 后續(xù)有更新比如房屋拆除、填方造地、道路改線你就得重新生成對應區(qū)域的瓦片。這個時候全量重切不現(xiàn)實我的建議是保持原始 DEM 預處理階段的數(shù)據(jù)組織不變只在業(yè)務區(qū)新增或替換的小塊 GeoTIFF 上重新執(zhí)行同樣的預處理和切片命令然后把新瓦片覆蓋回原目錄。有一點要提醒覆蓋前先備份layer.json并確認新老瓦片的層級范圍和坐標系完全一致。我遇到過一次新瓦片是投影坐標系老瓦片是 WGS84兩者在業(yè)務區(qū)邊緣直接錯位了兩三百米。后來我養(yǎng)成了每次重新切片之后先用 Cesium 飛到業(yè)務區(qū)邊緣看一圈的習慣雖然土但非常可靠。5.2 視覺與功能擴展地形數(shù)據(jù)的下一步地形數(shù)據(jù)生成成功后視覺進一步升級的方向主要有這些疊加影像圖真實地表色調(diào)加真實地形起伏效果直接提升一個檔次。開啟地形光照配置requestVertexNormals后再加上viewer.scene.globe.enableLighting true山體陰影會隨相機視角變化比扁平顏色真實得多。加水體遮罩如果業(yè)務區(qū)有水庫、湖泊且 DEM 高程準確設置requestWaterMask: true后可以看到動態(tài)水面效果。動態(tài)地形分析這類能力適合科研類項目比如模擬洪水淹沒線、填方挖方分析普通展示項目用不到知道有這層能力即可。給后面要做類似項目的同學一個最終建議先花十分鐘gdalinfo看清原始 DEM 的坐標系、位深、無效值再決定后面怎么切切片參數(shù)里max-level寧可低一點也別因為高而產(chǎn)生海量無用的細碎瓦片加載到 Cesium 之后先用maximumScreenSpaceError 4做性能驗證再決定要不要開更高精度。我個人最終把整套流程固化成了一個腳本輸入原始 DEM 區(qū)域范圍自動完成重投影、裁剪、切片、靜態(tài)部署最后跑一個 curl 模擬 Cesium 請求layer.json和任意一層瓦片確認 HTTP 返回正常。這個腳本在之后幾次數(shù)據(jù)更新中幫我省了大量重復勞動。如果你也在做 Cesium 離線項目建議盡早建立起自己的“預處理 切片 校驗”閉環(huán)這比每次手動操作要穩(wěn)得多。