数据访问层: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 判断。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 地形系统中的 数据源抽象层。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
左边瓦片:level 12,粗网格右边瓦片:level 13,细网格
TerrainProvider.requestTileGeometry 返回地形数据后,Cesium 拿到的还不是可以直接渲染的 GPU 资源。//QuantizedMeshTerrainData├── quantizedVertices├── indices├── encodedNormals├── minimumHeight├── maximumHeight├── boundingSphere├── orientedBoundingBox├── horizonOcclusionPoint├── westIndices├── southIndices├── eastIndices├── northIndices├── waterMask└── childTileMask
QuantizedMeshTerrainData.prototype.createMeshcreateMesh 会拿到当前瓦片的 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);}}}
1. 可以自然形成父子瓦片替换关系;2. 可以用父瓦片作为 fallback;3. 可以避免子瓦片未加载完成时画面出现空洞;4. 可以稳定控制 LOD 变化;5. 可以维护 TileReplacementQueue 缓存。
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);}}
QuadtreePrimitive 负责“选哪些瓦片”,那么 GlobeSurfaceTileProvider 负责“这些瓦片怎么变成地球表面”。//地形 mesh影像图层 imagery水面 water mask顶点法线 vertex normalsclipping planesclipping polygonsrender stateshader setdraw commandpick command
GlobeSurfaceTileProvider 的 terrainProvider setter 很关键:当 terrainProvider 变化时,如果已经关联 quadtree,会调用 quadtree.invalidateAllTiles(),这意味着地形数据源变化会导致四叉树瓦片整体失效并重新加载。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();};
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 很有意思。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。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↓进入本帧渲染列表
//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 表面
Accept header 请求扩展,例如同时请求 vertex normals 和 water mask:application/vnd.quantized-mesh;extensions=octvertexnormals-watermask。//普通法线:x, y, z floatoct normal:x, y uint8
最后用一张更偏源码的流程图总结:
//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地形渲染可写的内容很多, 后面再详细介绍介绍!
夜雨聆风