
几个月前,我们推出了 Evergine.Cesium 插件,将流式加载的全球级数字地球带入您的 .NET 应用程序。从那时起,我们的大部分工作都围绕着一个目标展开:让它更快。我们重构了插件将 Cesium 瓦片转化为像素的方式;新渲染器的流式加载更加平滑,在相同的 CPU 时间内能绘制多得多的瓦片,并保持显存占用的可预测性。
简而言之,瓦片不再是场景实体。它们的几何体存入一个大型共享缓冲区,影像则存入单张纹理图集。GPU 负责决定绘制内容,并通过单次间接绘制调用(Indirect Draw Call)渲染所有可见瓦片;与此同时,Cesium Native 在自己的独立线程上运行,不再与主帧争夺资源。本文接下来的部分将解释我们做出这些改动的原因,以及它们是如何协同工作的。
本文内容
为什么数字地球对渲染器是严峻挑战
3D 地球是以瓦片形式流式加载的。Cesium Native 会根据摄像机的位置,计算出需要哪些地形、影像和建筑区块,以及对应的细节层次(LOD)。飞越一座城市时,屏幕上可能同时存在数百个瓦片。移动摄像机,其中许多瓦片就会被替换:新瓦片从网络传输过来,旧瓦片则被丢弃。
这种工作负载与典型的游戏关卡截然不同。游戏关卡只需一次性加载网格,随后便可持续绘制数分钟。而数字地球在渲染的同时,还在不断创建和销毁 GPU 资源——上传每一个新到达的瓦片,释放每一个离开视野的瓦片,并且这一切都必须在不影响帧率的情况下完成。因此,真正关键的开销不仅在于绘制一个瓦片有多快,还在于加载、保留和卸载瓦片的代价有多低。
旧方法的不足之处
第一版扩展插件采用了最直接的方案,将每个瓦片转换为常规的 Evergine 模型。Cesium 将瓦片作为 glTF 模型交付给我们,我们在内存中将其写出为 GLB 文件,随后再用 Evergine 的模型加载器重新解析。加载器会构建一个带有独立网格和材质的实体并将其添加到场景中,而每块影像都会变成一张单独的纹理,稍后绑定到对应的材质上。Cesium 本身在主线程上更新,负责添加和移除这些实体的队列也在主线程上运行。
这种做法很简单,在小场景中表现良好,但无法扩展到大规模场景。每个传入的瓦片都要经历一次 GLB 往返转换,并创建一整套全新的实体、网格、材质和纹理,因此当大量瓦片同时加载时就会出现掉帧卡顿。此外,每个瓦片在每个渲染通道中都是单独绘制的,这意味着当屏幕上有数百个瓦片时,CPU 在一帧中要花费大量时间来提交绘制调用(Draw Call),而阴影计算更是让这一开销成倍增加。不仅如此,每分钟都有成千上万个小型缓冲区和纹理被创建和销毁,导致内存占用难以预测,而且所有这些操作都与您的应用程序运行在同一个线程上。
全新渲染器概览
新渲染器将瓦片视为数据而非实体。它不再为每个瓦片单独创建资源,而是预先一次性创建几个大型资源,并在瓦片加载和卸载时动态分配其中的区块。
从应用程序的角度来看,用法几乎没有变化:您依然只需向场景中添加一个 CesiumCoordinator,地球就会显现出来。但在底层,几乎所有机制都已焕然一新。
新特性
单一缓冲区容纳所有几何体
现在,所有瓦片几何体都存放在单一的顶点缓冲区和单一的索引缓冲区中,这两个缓冲区均在启动时一次性创建。当瓦片到达时,插件会直接从 Cesium 的 glTF 访问器中读取其位置、法线、纹理坐标和索引,省去了 GLB 的往返转换,随后将这些数据打包成交错的顶点格式,并直接复制到共享缓冲区中属于该瓦片的分片区域内。
由于每个流式加载进来的瓦片都需要分配分片,这一过程必须极快。为此,我们使用了 Sebastian Aaltonen 的 OffsetAllocator 的 C# 移植版,它正是为此类任务量身打造的。它将空闲空间按大小分类存放到不同的桶(bin)中,因此无论缓冲区是空的还是接近满载,分配或释放内存块都只需恒定的常数时间,同时还能保持较低的内存碎片率。除了几何数据外,每个瓦片在 GPU 的元数据缓冲区中都有一个小条目,用于记录其顶点、索引和影像的位置以及变换方式,因此添加或移除瓦片时只需修改其自身的条目即可。
单一图集容纳所有影像
卫星照片等影像数据会贴合覆盖在地形表面,Cesium 会将其拆分成小块进行传输。插件现在不再为每个小块单独创建纹理,而是将它们全部打包进一张大型纹理图集(Texture Atlas)中。图集按行(或称为“架层”)进行组织,每张图像都会被放入最契合其尺寸的架层中;当某个瓦片被释放时,其占用的空间会被下一张需要它的图像复用。
将像素写入图集的过程不会阻塞 GPU。Cesium 的加载线程会将图像写入暂存缓冲区(Staging Buffer),两个暂存缓冲区交替工作,以便 CPU 在填充其中一个的同时,GPU 可以从另一个中读取数据。每帧执行一次的计算着色器(Compute Shader)会将所有待处理的图像复制到图集中的对应位置,并在此过程中将其颜色从 sRGB 转换到线性空间。每张图像周围还带有 4 个纹素宽度的边框,用于重复其边缘像素,这样纹理过滤时就绝不会采样到相邻图像的颜色,从而避免瓦片之间出现接缝。最后,当图集的占用率超过阈值时,插件会请求 Cesium 释放不再需要的瓦片,从而在长时间漫游飞行期间将影像内存占用控制在合理范围内。
由 GPU 构建绘制列表
这是改动最大的地方。在传统渲染器中,CPU 需要遍历每个对象并为其记录一次绘制调用;当面对数百个瓦片和多个渲染通道时,这个循环很快就会成为性能瓶颈。新渲染器将这一过程转移到了 GPU 上。
当 Cesium 选定当前视图所需的瓦片后,CPU 唯一的工作就是写入一个可见性掩码,每个瓦片仅占一个比特位。在 GPU 端,计算着色器为每个瓦片运行一个线程并读取对应的比特位。每个可见瓦片都会申请一个槽位,并将其绘制命令写入间接参数缓冲区;同一个着色器还会计算出该瓦片的世界矩阵和世界-视图-投影(WVP)矩阵,从而避免顶点着色器为每个顶点重复执行矩阵乘法。最后,渲染器只需发出一次间接绘制调用(Indirect Draw),即可从共享缓冲区中读取几何体、从图集中读取影像,一次性渲染出所有可见瓦片。
最终结果是,CPU 的工作负载不再随着屏幕上瓦片数量的增加而增长。无论是 50 个瓦片还是 500 个瓦片,它都只需写入一个小型位掩码、分派一个计算着色器,并在每个渲染通道中发起一次绘制调用。如果你需要对比渲染结果或排查驱动问题,可以使用 UseCPUDrawCalls 选项切回在 CPU 上构建绘制命令的模式,同时仍然使用共享缓冲区和纹理图集。
Cesium 独占独立线程
Cesium Native 现在运行在专用线程上。每一帧,主线程都会将最新的相机数据传递给它,Cesium 线程则负责选择瓦片、分派加载任务并生成新的可见性掩码。两个线程在每次更新结束时交换掩码,从而确保渲染器始终绘制完整且一致的瓦片集合。
Cesium 线程以较低的优先级运行,因此在高负载设备上会优先保障帧率。如果某次更新耗时超过一帧,渲染器会继续绘制上一组完整的瓦片,同时等待 Cesium 线程完成更新。地形高度查询同样通过该线程处理,其结果会在场景更新期间返回。
依然是 Evergine 的一等公民
采用自定义方式渲染瓦片并不意味着脱离引擎体系。瓦片使用基于 Evergine 标准光照构建的专用城市材质效果进行绘制,并参与常规的渲染通道。它们能够投射和接收阴影,因此可以与全新的阴影系统以及随时间移动的太阳配合工作,并且像场景中的其他不透明几何体一样参与深度预通道(depth pre-pass)。整个管线可在 DirectX 11、DirectX 12 和 Vulkan 上运行。
掌握 GPU 显存占用情况
使用固定大小的缓冲区意味着了解其填充程度至关重要。CesiumCoordinator.Diagnostics 现在会报告每个瓦片集(tileset)中的可见瓦片数量以及常驻显存的瓦片总数,同时提供顶点缓冲区、索引缓冲区和纹理图集的容量与使用率。借助这些计数器,你可以轻松构建自己的性能分析面板;此外,纹理图集本身也通过 TextureAtlas 暴露出来,你可以将其显示在屏幕上,实时观察影像数据的打包过程。
测试结果
为了验证这些改进带来的整体效果,我们在相同条件下对旧版与新版扩展插件进行了对比测试。两者使用相同的 Cesium 配置,在 4K 显示器上将相机对准整个马德里市,测试平台配备 Intel Core i7-14700K 和 NVIDIA GeForce RTX 4080 SUPER。为了让对比专注于瓦片渲染器本身,我们关闭了深度预通道、G-buffer 通道和后期处理,并使用 CapFrameX 记录了帧时间。
性能提升幅度超出了我们的预期。平均帧率从 40.4 FPS 提升到了 332.4 FPS,这意味着单帧耗时从将近 25 ms 缩短到了约 3 ms:速度提升了约 8.2 倍。
| 指标 | 上一版本 | 新渲染器 |
|---|---|---|
| 平均值 | 40.4 FPS (24.75 ms) | 332.4 FPS (3.01 ms) |
| P95 | 44.1 FPS (22.68 ms) | 355.5 FPS (2.81 ms) |
| P5 | 37.8 FPS (26.46 ms) | 290.1 FPS (3.45 ms) |
| P1 | 35.9 FPS (27.86 ms) | 281.2 FPS (3.56 ms) |
| 1% low 平均值 | 33.8 FPS (29.59 ms) | 274.9 FPS (3.64 ms) |
| 0.1% low 平均值 | 30.7 FPS (32.57 ms) | 257.4 FPS (3.89 ms) |
平均帧率只能说明一半的问题。代表最慢的 1% 和 0.1% 帧平均水平的 Low 帧指标,反映了渲染器在最差时刻的表现,而这些恰恰是用户实际感知到卡顿的帧。在这方面,性能提升同样显著:最慢帧的耗时从超过 32 ms 降至 4 ms 以内。新渲染器中哪怕表现最差的帧,也比旧渲染器中表现最好的帧快五倍以上;同时,最快帧(P95)与最慢帧(0.1% low)之间的差距也从约 10 ms 缩小到了约 1 ms。因此,地球渲染不仅速度更快,运行也更加平稳。
入门指南
如果你已经在使用该扩展插件,只需更新 Evergine.Cesium NuGet 包即可获得全新的渲染器。对于新项目,只需在场景中添加一个 CesiumCoordinator 以及需要流式加载的瓦片集即可:
using Evergine.Cesium;
using Evergine.Cesium.Core;
using Evergine.Cesium.Utils;
using Evergine.Framework;
public class MyScene : Scene
{
public override void RegisterManagers()
{
base.RegisterManagers();
// The scene must contain an entity tagged as "MainCamera" with a Camera3D component.
var cesium = new CesiumCoordinator()
{
AccessToken = "<YOUR_CESIUM_ION_TOKEN>",
GeocodingService = new AzureMapsGeocodingService("<YOUR_OPTIONAL_AZURE_MAPS_KEY>"),
EntityManager = this.Managers.EntityManager,
};
// Cesium World Terrain with Bing Maps imagery, plus OpenStreetMap buildings
cesium.AddOverlayedTileset(1, RasterOverlayProvider.BingAerial, "Terrain");
cesium.AddTileset(96188, "Buildings");
this.Managers.AddManager(cesium);
}
}
从先前版本升级
新渲染器带来了一些 API 变更。现在需要显式添加瓦片集:AddOverlayedTileset 用于叠加了影像的地形,而 AddTileset 则用于建筑物等几何体。你可以根据需要添加任意数量的瓦片集,并在之后按名称或资产 ID 将其移除。
地理编码(Geocoding)也采用了可插拔设计。AzureMapsKey 属性已被 GeocodingService 取代,后者接受 IGeocodingService 的任意实现。该扩展插件内置了 AzureMapsGeocodingService 和全新的 GoogleMapsGeocodingService,你也可以编写自己的实现。
由于瓦片不再作为实体(Entity)存在,因此在场景中搜索瓦片实体的代码将无法再找到它们。请改用 Diagnostics 来检查已加载的内容,并使用 QueryTerrainMinHeight 对地形进行采样。最后,新渲染器依赖于随扩展插件资产一起发布的计算着色器和材质效果,因此请确保程序集与资产来自同一版本。
立即试用并分享你的想法
准备好体验更快的飞行浏览了吗?立即更新 Evergine,添加最新的 Evergine.Cesium 包,流式加载你最喜爱的城市。如果你是首次接触该扩展插件,可以从Evergine.Cesium 简介开始入手,完整的参考资料请查阅Evergine 文档。如果遇到问题或有任何建议,欢迎在Evergine 反馈仓库中告诉我们。
我们非常期待看到你用它构建的作品。
