主图:从 Cesium ion 流式加载并使用全新 Evergine.Cesium 渲染器渲染的曼哈顿建筑群

几个月前,我们推出了 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),而阴影计算更是让这一开销成倍增加。不仅如此,每分钟都有成千上万个小型缓冲区和纹理被创建和销毁,导致内存占用难以预测,而且所有这些操作都与您的应用程序运行在同一个线程上。

全新渲染器概览

新渲染器将瓦片视为数据而非实体。它不再为每个瓦片单独创建资源,而是预先一次性创建几个大型资源,并在瓦片加载和卸载时动态分配其中的区块。

示意图:过去,每个瓦片都会生成一个 GLB、一个实体、一个网格、一种材质和一张纹理,并产生各自独立的绘制调用;现在,所有瓦片共享同一个缓冲区和同一个纹理图集,并通过一次间接绘制完成渲染

从应用程序的角度来看,用法几乎没有变化:您依然只需向场景中添加一个 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),即可从共享缓冲区中读取几何体、从图集中读取影像,一次性渲染出所有可见瓦片。

图解:Cesium 线程写入可见性掩码,计算着色器将其转换为绘制命令和变换矩阵,并通过单次间接绘制渲染所有可见瓦片

最终结果是,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)
P9544.1 FPS (22.68 ms)355.5 FPS (2.81 ms)
P537.8 FPS (26.46 ms)290.1 FPS (3.45 ms)
P135.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 反馈仓库中告诉我们。

我们非常期待看到你用它构建的作品。

José Antonio Collado Araque
作者
José Antonio Collado Araque
图形工程师 - 研究员