乐于分享
好东西不私藏

Cesium 地形数据管理与渲染技术:源码分析一

Cesium 地形数据管理与渲染技术:源码分析一
本篇是接着上篇的源码分析点击跳转
在 Cesium 中,地形渲染并不是“把一张高程图贴到地球上”这么简单。它背后是一套完整的 地形瓦片数据管理、四叉树调度、LOD 选择、异步加载、网格构建、影像贴合和 GPU DrawCommand 提交系统。
从宏观上看,Cesium 地形渲染可以拆成五层:
数据访问层:TerrainProvider数据格式层:QuantizedMeshTerrainData / HeightmapTerrainData瓦片调度层:QuadtreePrimitive表面组织层:GlobeSurfaceTileProvider / GlobeSurfaceTileGPU 渲染层:TerrainMesh / VertexArray / DrawCommand
其中最核心的一条主线是:
//Viewer  ↓Scene.render  ↓Globe.render  ↓QuadtreePrimitive.render  ↓GlobeSurfaceTileProvider.beginUpdate  ↓selectTilesForRendering  ↓createRenderCommandsForSelectedTiles  ↓GlobeSurfaceTileProvider.endUpdate  ↓frameState.commandList  ↓Renderer 执行 DrawCommand
官方 QuadtreePrimitive 源码注释里已经把它的核心职责说得很清楚:它用于渲染海量数据,通过 LOD 和裁剪来选择瓦片;地球表面被划分为四叉树瓦片,根部是大范围低精度瓦片,叶子是小范围高精度瓦片;瓦片是否继续细分,依据几何误差投影到屏幕后的 screen-space error 判断。
一、地形数据入口:TerrainProvider
在 Cesium 里,地形数据的入口不是 Globe,是 TerrainProvider。
典型使用方式如下:
const viewer = new Cesium.Viewer("cesiumContainer", {  terrainProvider: await Cesium.CesiumTerrainProvider.fromUrl(url, {    requestVertexNormals: true,    requestWaterMask: true  })});
CesiumTerrainProvider 官方文档说明,它用于访问 Cesium terrain format,支持的格式包括 Quantized Mesh 和 Height Map;同时它提供 requestTileGeometry(x, y, level, request) 方法,用来请求某个瓦片的地形几何数据。
源码层面可以把 TerrainProvider 理解为一层数据适配接口:
//TerrainProvider├── tilingScheme├── availability├── hasWaterMask├── hasVertexNormals├── getLevelMaximumGeometricError(level)├── getTileDataAvailable(x, y, level)└── requestTileGeometry(x, y, level, request)
它不负责渲染,也不负责四叉树调度,而是专门回答几个问题:
//这个瓦片有没有数据?这个瓦片的几何误差是多少?这个瓦片该从哪个 URL 请求?返回的数据是 Heightmap 还是 Quantized Mesh?是否带 water mask?是否带 vertex normals?是否有子瓦片可用性信息?
也就是说,TerrainProvider 是 Cesium 地形系统中的 数据源抽象层。
二、Quantized Mesh:Cesium 地形的主力格式
Cesium 支持 Heightmap Terrain 和 Quantized Mesh Terrain,但真实生产环境中更常见的是 Quantized Mesh。
官方文档中对 QuantizedMeshTerrainData 的定义是:一个 quantized mesh 由经度、纬度、高度三个顶点属性组成,这些属性都被表示为 0 到 32767 范围内的 16 位值;经纬度相对于瓦片西南角和东北角插值,高度相对于该瓦片的最小高度和最大高度插值。
也就是说,瓦片里的顶点并不是直接存储:
{  longitude: 116.391,  latitude: 39.907,  height: 142.5}
而是存储为:
{  u: 23124,  v: 18452,  height: 10566}
然后根据瓦片矩形范围和高度范围反算真实位置:
u / v / quantizedHeight        ↓longitude / latitude / height        ↓Cartesian3        ↓TerrainMesh        ↓GPU vertex buffer
Quantized Mesh 规范还说明,瓦片边界会保存 west、south、east、north 四个方向的边界顶点索引,这些索引用于添加 skirt,从而隐藏不同 LOD 瓦片之间的裂缝。
这个设计非常关键,因为地形瓦片经常会出现这种情况:
左边瓦片:level 12,粗网格右边瓦片:level 13,细网格
如果两个瓦片直接拼接,边界顶点数量不一致,就可能出现裂缝。Cesium 通过边界索引和 skirt 几何,把瓦片边缘向下延伸一圈,让裂缝不容易暴露出来。
三、从 TerrainData 到 TerrainMesh:createMesh 是关键
当 TerrainProvider.requestTileGeometry 返回地形数据后,Cesium 拿到的还不是可以直接渲染的 GPU 资源。
以 Quantized Mesh 为例,返回的是:
//QuantizedMeshTerrainData├── quantizedVertices├── indices├── encodedNormals├── minimumHeight├── maximumHeight├── boundingSphere├── orientedBoundingBox├── horizonOcclusionPoint├── westIndices├── southIndices├── eastIndices├── northIndices├── waterMask└── childTileMask
真正把它变成可渲染网格的是:
QuantizedMeshTerrainData.prototype.createMesh
从源码看,createMesh 会拿到当前瓦片的 tilingScheme / x / y / level,根据 tileXYToRectangle 算出瓦片经纬度范围,然后通过 TaskProcessor("createVerticesFromQuantizedTerrainMesh") 把网格生成任务丢给 worker 处理。它传入的数据包括最小/最大高度、量化顶点、法线、索引、四边边界索引、skirt 高度、瓦片 rectangle、椭球、夸张高度参数等。
简化后的源码流程可以理解成:
QuantizedMeshTerrainData.prototype.createMesh = function (options) {  const tilingScheme = options.tilingScheme;  const rectangle = tilingScheme.tileXYToRectangle(x, y, level);  const verticesPromise = taskProcessor.scheduleTask({    minimumHeight,    maximumHeight,    quantizedVertices,    octEncodedNormals,    indices,    westIndices,    southIndices,    eastIndices,    northIndices,    westSkirtHeight,    southSkirtHeight,    eastSkirtHeight,    northSkirtHeight,    rectangle,    ellipsoid,    exaggeration  });  return verticesPromise.then(function (result) {    return new TerrainMesh(...);  });};
这里有两个重点。

第一,createMesh 通常是异步的,因为地形网格构建涉及大量顶点解码、坐标转换、skirt 生成和包围体计算,放到 worker 可以避免阻塞主线程。

第二,createMesh 返回的是 TerrainMesh,它已经非常接近 GPU 可用数据了:

//TerrainMesh├── center├── vertices├── indices├── minimumHeight├── maximumHeight├── boundingSphere├── orientedBoundingBox├── occludeePointInScaledSpace├── vertexStride└── encoding

所以可以这样理解:

//TerrainData 是地形数据表达TerrainMesh 是地形渲染网格表达VertexArray / IndexBuffer 是 GPU 资源表达DrawCommand 是 Cesium 渲染命令表达

四、QuadtreePrimitive:地形瓦片调度核心

地形数据能不能显示,不是 TerrainProvider 决定的,而是 QuadtreePrimitive 调度出来的。

QuadtreePrimitive 的源码里有几个重要成员:
this._tilesToRender = [];this._tileLoadQueueHigh = [];this._tileLoadQueueMedium = [];this._tileLoadQueueLow = [];this._tileReplacementQueue = new TileReplacementQueue();
源码中可以看到,它维护了三个加载队列:高优先级队列、中优先级队列、低优先级队列;高优先级瓦片通常是阻碍 refinement 的瓦片,中优先级瓦片是正在被渲染的瓦片,低优先级瓦片则是已经被细分过去或不可见区域相关的瓦片。

1. beginFrame:每帧准备

QuadtreePrimitive.beginFrame(frameState) 主要做本帧初始化:
1. 如果 tilesInvalidated,则释放并重建瓦片;2. 调用 tileProvider.initialize(frameState);3. 清空加载队列;4. 标记 TileReplacementQueue 的帧开始;5. 清空本帧 rendered tiles 集合。
源码中 beginFrame 会调用 tileProvider.initialize(frameState),清理 load queue,并调用 _tileReplacementQueue.markStartOfRenderFrame()。
这里的 tileProvider 对地形来说,就是 GlobeSurfaceTileProvider。
2. render:真正选择瓦片并创建命令
QuadtreePrimitive.render(frameState)是地形调度中非常关键的方法。源码逻辑很清楚:
//QuadtreePrimitive.prototype.render = function (frameState) {  if (passes.render) {    tileProvider.beginUpdate(frameState);    selectTilesForRendering(this, frameState);    createRenderCommandsForSelectedTiles(this, frameState);    tileProvider.endUpdate(frameState);  }  if (passes.pick && this._tilesToRender.length > 0) {    tileProvider.updateForPick(frameState);  }};
这段源码说明了主渲染阶段的顺序:先 beginUpdate,再选择瓦片,然后为选中的瓦片创建渲染命令,最后 endUpdate;如果是 pick pass,还会走 updateForPick。
这条链路是理解 Cesium 地形渲染的核心:
QuadtreePrimitive.render    ↓GlobeSurfaceTileProvider.beginUpdate    ↓selectTilesForRendering    ↓createRenderCommandsForSelectedTiles    ↓GlobeSurfaceTileProvider.endUpdate
3. endFrame:处理加载队列
QuadtreePrimitive.endFrame(frameState)并不负责提交 draw command,而是处理地形和影像的后续加载:
//processTileLoadQueueupdateHeightsupdateTileLoadProgress
源码注释里写得很直接:它会加载或创建 terrain 和 imagery 资源,并为下一帧准备纹理重投影。
所以 Cesium 的地形渲染不是“一帧里全部完成”,而是一个跨帧渐进过程:
当前帧选择可渲染瓦片当前帧发现缺失资源当前帧加入加载队列后续帧继续加载 terrain / imagery / mesh / texture资源 ready 后再进入 render list
五、selectTilesForRendering:LOD 选择的核心
selectTilesForRendering 是 QuadtreePrimitive 中最值得重点看的函数之一。
它的职责可以概括成:
//从 level 0 根瓦片开始  ↓判断可见性  ↓判断 screen-space error  ↓决定渲染当前瓦片,还是继续访问子瓦片  ↓把最终选中的瓦片加入 _tilesToRender  ↓把需要加载的瓦片加入不同优先级队列
源码里,selectTilesForRendering 会先创建 level zero tiles,然后设置 occluder 的相机位置,再按照根瓦片中心到相机的距离排序,从近到远遍历。
简化流程如下:
function selectTilesForRendering(primitive, frameState) {  primitive._tilesToRender.length = 0;  if (!primitive._levelZeroTiles) {    primitive._levelZeroTiles = QuadtreeTile.createLevelZeroTiles(tilingScheme);  }  sortLevelZeroTilesByDistanceToCamera();  for (const tile of levelZeroTiles) {    if (!tile.renderable) {      queueTileLoad(highPriorityQueue, tile);    } else {      visitIfVisible(tile);    }  }}
这里有一个很重要的点:Cesium 不是直接从高层级瓦片开始找,而是从 level 0 根瓦片开始递归遍历。
这样做的好处是:
1. 可以自然形成父子瓦片替换关系;2. 可以用父瓦片作为 fallback;3. 可以避免子瓦片未加载完成时画面出现空洞;4. 可以稳定控制 LOD 变化;5. 可以维护 TileReplacementQueue 缓存。
六、visitTile:SSE、细分和父子瓦片替换
visitTile 是四叉树 LOD 选择中最核心的一段。
源码中会先计算:
const meetsSse =  screenSpaceError(primitive, frameState, tile) <  primitive.maximumScreenSpaceError;
如果当前瓦片满足 SSE,就说明这个瓦片的精度已经够了,可以考虑渲染当前瓦片;如果不满足,就需要继续访问子瓦片。源码中 visitTile 先计算 meetsSse,然后根据瓦片是否 renderable、是否上帧渲染过、是否完全加载、是否可以不丢失细节等条件,决定是渲染当前瓦片还是继续细分。
可以把核心逻辑简化成:
function visitTile(tile) {  const meetsSse = screenSpaceError(tile) < maximumScreenSpaceError;  if (meetsSse) {    if (tile 可以安全渲染) {      addTileToRenderList(tile);      queueTileLoad(medium, tile);      return;    }    // 如果当前瓦片还不能安全替代上帧细节,    // 就继续渲染上帧子瓦片,避免视觉细节突然消失    ancestorMeetsSse = true;    queueTileLoad(high, tile);  }  if (tileProvider.canRefine(tile)) {    visitVisibleChildrenNearToFar(tile.children);  } else {    queueTileLoad(high, tile);  }}
Cesium 的 LOD 不是简单的“满足 SSE 就渲染,不满足就细分”。它还要考虑 上一帧渲染状态。
为什么?
因为如果上一帧已经显示了 level 15 的精细瓦片,而这一帧因为相机移动或资源状态变化,突然回退到 level 10,会出现明显的“细节消失”闪烁。Cesium 为了避免这种视觉跳变,会尽量保持上一帧已经显示的细节,直到更合适的替代瓦片准备好。
源码注释里也明确提到:如果上帧正在渲染更高层级瓦片,而这一帧想回退到较粗层级,但较粗瓦片还不能安全替代,就会继续保留可见后代瓦片,避免画面细节突然消失。
这也是 Cesium 地形浏览比较平滑的原因之一。
七、GlobeSurfaceTileProvider:地形与影像的组织者
如果说 QuadtreePrimitive 负责“选哪些瓦片”,那么 GlobeSurfaceTileProvider 负责“这些瓦片怎么变成地球表面”。
它不只是处理地形,还要处理:
//地形 mesh影像图层 imagery水面 water mask顶点法线 vertex normalsclipping planesclipping polygonsrender stateshader setdraw commandpick command
源码中 GlobeSurfaceTileProvider 的 terrainProvider setter 很关键:当 terrainProvider 变化时,如果已经关联 quadtree,会调用 quadtree.invalidateAllTiles(),这意味着地形数据源变化会导致四叉树瓦片整体失效并重新加载。
1. initialize:处理影像重投影与 credit
每帧开始时,GlobeSurfaceTileProvider.initialize(frameState) 会处理 imagery layer 的纹理重投影命令,并在影像图层顺序变化时重排各瓦片上的 imagery 列表;同时它还会把 terrain 和 imagery provider 的 credit 添加到下一帧显示。
可以简化成:
GlobeSurfaceTileProvider.prototype.initialize = function (frameState) {  imageryLayers.queueReprojectionCommands(frameState);  if (layerOrderChanged) {    quadtree.forEachLoadedTile(tile => {      tile.data.imagery.sort(sortTileImageryByLayerIndex);    });  }  updateCredits(this, frameState);  destroyDeferredVertexArrays();};
这说明,地形瓦片不是孤立渲染的,它和影像层紧密绑定。
2. beginUpdate:清空本帧临时状态
beginUpdate会清空 _tilesToRenderByTextureCount,更新 clipping planes / clipping polygons,并重置 _usedDrawCommands、_hasLoadedTilesThisFrame、_hasFillTilesThisFrame 等状态。
简化如下:
GlobeSurfaceTileProvider.prototype.beginUpdate = function (frameState) {  clearTilesToRenderByTextureCount();  if (clippingPlanes.enabled) {    clippingPlanes.update(frameState);  }  if (clippingPolygons.enabled) {    clippingPolygons.update(frameState);    clippingPolygons.queueCommands(frameState);  }  this._usedDrawCommands = 0;  this._hasLoadedTilesThisFrame = false;  this._hasFillTilesThisFrame = false;};
这里的 _tilesToRenderByTextureCount 很有意思。
Cesium 会按照当前瓦片可用影像纹理数量对瓦片分组。因为不同瓦片绑定的 imagery 数量可能不一样,不同 texture count 可能对应不同 shader / uniform / command 组织方式。
3. endUpdate:创建 RenderState 并提交 DrawCommand
endUpdate 是地形渲染中非常关键的阶段。源码中如果 _renderState 还没有创建,会先创建默认 render state、blend render state、关闭 culling 的 render state 等;默认状态开启 cull 和 depth test,blend 状态开启 alpha blend。
简化后:
GlobeSurfaceTileProvider.prototype.endUpdate = function (frameState) {  if(!this._renderState) {    this._renderState = RenderState.fromCache({      cull: { enabled: true },      depthTest: {        enabled: true,        func: DepthFunction.LESS      }    });    this._blendRenderState = RenderState.fromCache({      cull: { enabled: true },      depthTest: {        enabled: true,        func: DepthFunction.LESS_OR_EQUAL      },      blending: BlendingState.ALPHA_BLEND    });  }  for(const tiles of tilesToRenderByTextureCount) {    for(const tile of tiles) {      addDrawCommandsForTile(this, tile, frameState);    }  }};
源码中后续会遍历 _tilesToRenderByTextureCount,对每个 tile 调用 addDrawCommandsForTile(this, tile, frameState),并更新 frameState.minimumTerrainHeight。
也就是说,最终地形 draw command 不是在 QuadtreePrimitive 里直接创建的,而是由 GlobeSurfaceTileProvider 根据选中的瓦片统一组织出来。
八、地形瓦片的生命周期
一个地形瓦片从“被需要”到“显示在屏幕上”,大致会经历下面几个阶段:
//1. QuadtreePrimitive 遍历到该 tile2. 判断 tile 可见3. 判断 tile SSE 不满足,需要加载或细分4. queueTileLoad 加入加载队列5. endFrame 中 processTileLoadQueue6. GlobeSurfaceTileProvider.loadTile7. TerrainProvider.requestTileGeometry8. 返回 QuantizedMeshTerrainData9. QuantizedMeshTerrainData.createMesh10. worker 生成 TerrainMesh11. 创建 GPU VertexArray / IndexBuffer12. tile.renderable = true13. 下一帧进入 _tilesToRender14. endUpdate 中 addDrawCommandsForTile15. DrawCommand push 到 frameState.commandList16. Renderer 执行 WebGL draw
这条生命周期里有两个“跨帧点”:
//requestTileGeometry 是异步网络请求createMesh 是异步 worker 任务
所以地形瓦片不是“请求回来就马上显示”,而是:
数据 ready    ↓mesh ready    ↓GPU resource ready    ↓renderable    ↓进入本帧渲染列表
九、地形、影像与 shader 的关系
Cesium Globe 表面最终显示效果来自三类数据:
//Terrain Mesh:决定地球表面的三维起伏Imagery Texture:决定地表颜色Globe Shader:决定光照、水面、雾、裁剪、地下等效果
所以地形瓦片本身只解决“几何问题”,影像图层解决“颜色问题”。
一个瓦片最终渲染时,大概需要这些资源:
tile.data.meshtile.data.vertexArraytile.data.imagery[]tile.data.waterMaskTexturetile.data.terrainDatatile.data.tileBoundingRegion
这也是为什么 GlobeSurfaceTileProvider 比 TerrainProvider 复杂很多:它不仅关心地形 mesh,还要把多个 imagery layer 正确贴到地形网格上。
可以简单理解为:
//TerrainProvider 只提供地形ImageryProvider 只提供影像GlobeSurfaceTileProvider 负责把二者合成 Globe 表面
十、Water Mask 与 Vertex Normals
Quantized Mesh 支持扩展数据。
Quantized Mesh 规范中,客户端可以通过 Accept header 请求扩展,例如同时请求 vertex normals 和 water mask:application/vnd.quantized-mesh;extensions=octvertexnormals-watermask。
1. Vertex Normals
地形法线用于光照。
Quantized Mesh 规范中,oct-encoded per-vertex normals 会把传统 xyz 三分量单位向量压缩成 xy 两个 8-bit 分量,即八面体压缩编码。有关八面体编码可以移步这篇文章
//普通法线:x, y, z floatoct normal:x, y uint8
这样既能降低传输量,也能让地形在光照下有更好的立体感。
2. Water Mask
water mask 用于标识瓦片中的水域。规范中说,如果瓦片全是陆地或全是水,water mask 可以只有 1 字节;如果是混合区域,则是 256 × 256 的 mask,0 表示陆地,255 表示水域。
这部分数据会影响海面、水体反射、动态波纹等效果。
十一、完整调用链总结

最后用一张更偏源码的流程图总结:

//Viewer 初始化  ↓new Globe()  ↓Globe 内部创建 GlobeSurfaceTileProvider  ↓GlobeSurfaceTileProvider 持有 terrainProvider / imageryLayers  ↓Globe 内部通过 QuadtreePrimitive 管理地表瓦片  ↓每帧 Scene.render  ↓Globe.render  ↓QuadtreePrimitive.beginFrame  ├── tileProvider.initialize  ├── clearTileLoadQueue  └── tileReplacementQueue.markStartOfRenderFrame  ↓QuadtreePrimitive.render  ├── tileProvider.beginUpdate  ├── selectTilesForRendering  │     ├── createLevelZeroTiles  │     ├── visitIfVisible  │     ├── visitTile  │     ├── screenSpaceError  │     ├── canRefine  │     ├── queueTileLoad  │     └── addTileToRenderList  ├── createRenderCommandsForSelectedTiles  │     └── tileProvider.showTileThisFrame  └── tileProvider.endUpdate        ├── create RenderState        ├── group tiles by texture count        ├── addDrawCommandsForTile        └── push command to frameState.commandList  ↓QuadtreePrimitive.endFrame  ├── processTileLoadQueue  │     └── GlobeSurfaceTileProvider.loadTile  │           ├── requestTileGeometry  │           ├── createMesh  │           ├── create vertex array  │           └── load imagery  ├── updateHeights  └── updateTileLoadProgress  ↓Renderer 执行 commandList  ↓WebGL 绘制地形

十二、文章结论

Cesium 地形系统的核心不是单点算法,而是一整套流式渲染架构。

它

通过 TerrainProvider 抽象地形数据源,

通过 QuantizedMeshTerrainData 表达压缩后的地形瓦片,

通过 QuadtreePrimitive 实现四叉树 LOD 调度,

通过 GlobeSurfaceTileProvider 把地形、影像、水面、裁剪、shader 统一组织成地球表面,

最后通过 DrawCommand 进入 Cesium 的 WebGL 渲染管线。

可以用一句话概括:

Cesium 的地形渲染,本质上是一个以四叉树瓦片为调度单元,以 Quantized Mesh 为数据核心,以 DrawCommand 为 GPU 提交单位的全球地形流式渲染系统。

这也是为什么 Cesium 能在浏览器里渲染全球级地形数据:它不是一次性加载整个地球,而是每一帧都根据相机、误差、可见性和资源状态,动态决定“看哪里、加载哪里、渲染哪里”。

Cesium地形渲染可写的内容很多, 后面再详细介绍介绍!

相关学习资料