【Unity】 GIS 三维瓦片运行时调度:3D Tiles、屏幕空间误差与异步加载架构
关键词:Unity GIS、3D Tiles、LOD、屏幕空间误差、SSE、视锥裁剪、异步加载、Android、XR、城市级三维
摘要:城市级三维数据进入 Unity 后,真正困难的并不是“把模型显示出来”,而是如何根据相机、屏幕误差与设备预算,持续选择合适层级,并在移动中完成异步加载、主线程提交和缓存回收。本文从 3D Tiles 层级结构出发,推导屏幕空间误差,设计视锥裁剪、请求优先级、瓦片状态机和双预算缓存,最终形成一套适合 Android 与 XR 一体机的运行时调度架构。

一、选题定位:为什么值得单独写一篇
这篇文章聚焦一个明确问题:在 Unity 中,如何让城市级三维瓦片随着视点移动稳定加载,同时避免远景过细、近景空洞、主线程卡顿和显存持续上涨?
它不讨论数据生产软件的按钮操作,也不把 Cesium、S3M、OSGB 或 3D Tiles 简单排成优劣表;它关注的是这些格式背后共同的运行时问题:空间层级、误差驱动的 LOD、并发请求、渲染资源提交和内存淘汰。
与近期已经推进的 XR 控制器输入、头显佩戴检测、VR 大场景美术制作流程相比,本篇更偏 GIS 引擎底层与移动端性能工程。文章也不会试图实现完整 3D Tiles 1.1 解析器,而是先构建一个可解释、可测量、可替换数据后端的调度内核。
二、先纠正一个常见思路:距离不等于 LOD
很多项目的第一版会按距离切换模型:距离相机 100 米显示 LOD0,500 米显示 LOD1,更远显示 LOD2。这个方法在固定尺寸的普通模型上够用,但放到城市级 GIS 数据中会很快失效。
原因并不复杂:同样距离相机 500 米,一栋 300 米高的建筑与一个 2 米宽的路灯,对屏幕的影响完全不同;同一瓦片在手机竖屏、4K 显示器和双目 XR 中占据的像素也不同;相机 FOV 改变后,固定距离阈值不会自动调整。真正需要控制的不是世界空间距离,而是简化误差投影到屏幕上有多大。
3D Tiles 使用 geometricError 描述瓦片相对于更精细内容的几何误差。客户端再结合视点距离、视口高度和相机视场角,把它换算成以像素为单位的 Screen-Space Error,简称 SSE。OGC 3D Tiles 1.1 规范把最大允许 SSE 作为客户端细化判断的重要依据;Cesium Native 的选择算法也围绕“当前瓦片的 SSE 是否足以继续渲染,还是应细化到子节点”展开。
这带来一个关键转变:
LOD 不是预先写死的距离表,而是相机状态、数据误差和设备质量预算共同作用的结果。
三、瓦片树只描述空间,不负责加载
一个可维护的系统应把“元数据”和“运行时内容”分开。瓦片树可以在进入场景时解析,但 Mesh、纹理和材质不应该同时全部创建。
public enum TileRefineMode
{
Replace,
Add
}
public sealed class TileNode
{
public string Id;
public Bounds WorldBounds;
public double GeometricError;
public TileRefineMode RefineMode;
public string ContentUri;
public readonly List<TileNode> Children = new();
// 运行时字段可拆入单独对象,这里为便于说明放在一起。
public TileRuntimeState State;
public GameObject Instance;
public long EstimatedGpuBytes;
public int LastVisibleFrame;
}
Bounds 只是示例。真实 3D Tiles 的包围体可能是 box、sphere 或 region;region 还涉及经纬度和椭球高。工程中可以统一抽象为 ITileBoundingVolume,分别提供视锥相交、相机距离和调试绘制方法。不要为了方便过早把所有地理包围体粗暴转换成轴对齐包围盒,否则在大范围、旋转建筑或细长道路上会产生大量误选。
元数据层至少要保留:
| 字段 | 调度意义 |
|---|---|
| Bounding Volume | 判断是否可能进入当前视野 |
| Geometric Error | 计算 SSE,决定是否细化 |
| Refine | 决定父子内容替换或叠加 |
| Transform | 将局部内容放到正确空间 |
| Content URI | 延迟请求模型或外部 tileset |
| Children | 建立空间层级与细化路径 |
四、屏幕空间误差如何计算

在透视相机下,可以使用下面的近似式:
SSE = geometricError / distance
× viewportHeight
/ (2 × tan(verticalFov / 2))
其中:
geometricError:当前瓦片对应的世界空间误差;distance:相机到瓦片包围体最近点的距离,而非到中心点的距离;viewportHeight:当前眼睛或相机的实际渲染高度;verticalFov:垂直视场角;- SSE 结果单位近似为像素。
一个简化实现如下:
public static float CalculateSse(
double geometricError,
float distance,
int viewportHeight,
float verticalFovDegrees)
{
distance = Mathf.Max(distance, 0.01f);
float fovRadians = verticalFovDegrees * Mathf.Deg2Rad;
float denominator = 2f * Mathf.Tan(fovRadians * 0.5f);
return (float)(geometricError / distance)
* viewportHeight / denominator;
}
判断通常为:
bool shouldRefine = sse > maximumScreenSpaceError;
maximumScreenSpaceError 越小,系统越早细化,画面更精细,同时请求数量、三角形、纹理和内存压力都会上升。它不是“越小越好”的画质开关,更像是整个流式系统的总阀门。
4.1 为什么距离要取到包围体的最近点
如果使用瓦片中心点,一个覆盖数公里的瓦片会出现明显错误:相机已经贴近瓦片边缘,中心仍然很远,系统便误认为不需要细化。对 AABB 可以用 Bounds.ClosestPoint(cameraPosition),对球体用相机到球心距离减半径,对 region 则需要在统一地心坐标或局部切平面中计算。
4.2 XR 中不要直接照搬单眼参数
XR 有左右眼视图、动态分辨率和不同的渲染缩放。稳妥做法是分别评估活动视图,再取更严格的结果;工程折中则可以使用双眼中更大的视口高度与更小的 FOV。若使用固定 Screen.height,动态分辨率变化后,SSE 与真实渲染像素会脱节。
4.3 给细化与降级设置迟滞区间
如果阈值只有一条线,相机在边界附近轻微移动会导致父子瓦片反复加载和卸载。例如:
细化阈值 refineSse = 18
降级阈值 collapseSse = 12
当前使用父瓦片时,超过 18 才请求子瓦片;当前已经显示子瓦片时,低于 12 才退回父瓦片。这种迟滞与 XR 输入中按压/释放阈值的思路一致,目的都是避免临界抖动。
五、每帧遍历:先裁剪,再计算误差

一次标准遍历可以按以下顺序进行:
- 从根节点进入;
- 用包围体做视锥裁剪;
- 对可见节点计算距离与 SSE;
- SSE 达标则选择当前节点;
- SSE 超标且存在子节点则继续向下;
- 为缺失内容产生请求,但不在遍历函数中同步加载;
- 生成本帧期望渲染集合和候选卸载集合。
void Visit(TileNode tile, Camera camera, SelectionResult result)
{
if (!IntersectsFrustum(tile, camera))
return;
float distance = DistanceToBoundingVolume(tile, camera.transform.position);
float sse = CalculateSse(
tile.GeometricError,
distance,
camera.pixelHeight,
camera.fieldOfView);
bool canRefine = tile.Children.Count > 0;
if (canRefine && sse > settings.RefineSse)
{
foreach (TileNode child in tile.Children)
Visit(child, camera, result);
result.MarkFallback(tile);
return;
}
result.Select(tile, sse, distance);
}
这段代码展示意图,但还缺一个决定体验的细节:子节点未就绪时,父节点怎么办?
如果父节点立即隐藏,画面会出现洞;如果父节点永不隐藏,父子可能长期重叠,造成 Z-Fighting 和过度绘制。更合理的策略是把“期望细化”和“可提交细化”分开:遍历得到期望子节点集合;只有满足替换条件的子内容已经 Ready,才切换可见集合。在此之前保留父节点作为 fallback。
对于 REPLACE:子集具备足够覆盖后再隐藏父级。对于 ADD:父级本来就要与子级叠加,不能套用同一隐藏规则。
六、视锥裁剪不是完整答案
视锥裁剪可以排除相机背后的瓦片,但它不知道建筑是否被另一栋建筑完全遮挡,也不知道一个只占几像素的远处节点是否值得请求。移动端项目常见的合理顺序是:
- 层级包围体裁剪:最便宜,必须有;
- SSE 选择:控制几何与纹理细节;
- 地平线或地球遮挡:地球尺度场景才需要;
- 遮挡剔除:收益取决于城市密度与相机路径;
- Unity Occlusion Culling:更适合相对静态、可预烘焙的本地场景,不应假设它能自动解决动态流入的城市瓦片。
一个常见错误是:遍历到不可见节点后立刻卸载。用户快速回头时,刚释放的内容又要重新下载、解析与上传。正确做法是将“不可见”和“可淘汰”分开,保留一个宽限期,并让最近可见时间参与 LRU 排序。
七、异步加载要拆成三个阶段
瓦片加载不是一个单纯的 await。对 glTF/GLB 或自定义二进制模型,至少存在三类工作:
7.1 I/O 阶段
从本地磁盘、HTTP、AssetBundle 或 Addressables 读取字节。这个阶段通常可以异步,并受网络并发数约束。相机运动时要支持取消“仍在队列、尚未开始”的低优先级请求。
7.2 解码阶段
包括 JSON 解析、压缩解码、顶点重排、纹理解码以及坐标转换。纯数据工作应尽量放到工作线程,但不要在线程中直接创建 Unity GameObject、Mesh 或操作大多数 UnityEngine 对象。
7.3 主线程提交阶段
创建 Mesh、Texture、Material、Renderer 并挂接节点。它往往正是尖峰来源。即使网络和解析完全异步,如果同一帧提交十几个高精瓦片,仍然会卡顿。
因此,调度器需要两个独立限制:
[Serializable]
public sealed class StreamingBudget
{
public int MaxConcurrentRequests = 4;
public int MaxConcurrentDecodes = 2;
public float MaxMainThreadCommitMs = 2.0f;
public int MaxCommitsPerFrame = 2;
}
主线程提交可以使用秒表控制预算:一旦本帧累计超过 2 ms,就把剩余 Ready 数据留到下一帧,而不是为了“尽快显示”破坏 XR 的帧时间。
Addressables 提供异步加载句柄,并要求调用方管理引用与释放;若瓦片内容通过 Addressables 或 AssetBundle 组织,必须把句柄生命周期纳入瓦片状态,而不能只销毁实例。远程 3D Tiles 则通常需要自定义下载与 glTF 管线,但其“异步句柄 + 明确释放”的设计原则仍然适用。
八、用状态机控制重复请求、取消与迟到结果

推荐至少定义以下状态:
public enum TileRuntimeState
{
Unloaded,
Queued,
Loading,
Decoding,
Ready,
Visible,
Failed,
Evicting
}
状态机解决的不是代码“好看”问题,而是并发边界:
- 同一节点被左右眼或多个相机选中时不能重复请求;
- 节点离开视野时,可以移出队列,但已进入不可取消解码的任务可能仍会返回;
- 返回结果到达时,节点可能已不再需要;
- 失败请求不能每帧重试;
- 卸载时要防止对象仍被渲染集合引用。
可以给每次请求分配版本号:
int requestVersion = ++tile.RequestVersion;
TilePayload payload = await loader.LoadAsync(tile.ContentUri, token);
if (requestVersion != tile.RequestVersion || !tile.IsStillWanted)
{
payload.Dispose();
return;
}
commitQueue.Enqueue(new CommitCommand(tile, requestVersion, payload));
这样即使旧请求无法真正中止,其“迟到结果”也不会覆盖新状态。失败重试则应使用指数退避,例如 1、2、4、8 秒,并设置上限;404 或格式错误可标记为不可重试,网络超时才进入退避队列。
九、请求优先级:先解决用户正在看的地方
先进先出队列不适合自由移动相机。用户转头后,旧方向的大量请求仍排在前面,会造成“眼前一直糊,身后悄悄变清晰”。优先级应每隔若干帧重新评估,至少考虑:
priority = W1 × normalizedSse
+ W2 × centerOfViewScore
+ W3 × motionPredictionScore
+ W4 × parentFallbackPenalty
- W5 × distanceScore
- SSE 越高,说明当前误差越明显;
- 越接近屏幕中心,越值得先加载;
- 根据头部或相机角速度预测即将进入的区域,可减少转头时白块;
- 若某父节点只能靠该子节点完成覆盖替换,应提高优先级;
- 距离可以作为次级项,而不是唯一标准。
优先级没必要每帧对全部节点完整排序。可以使用二叉堆,或将请求粗分为 Critical、High、Normal、Prefetch 四档。XR 项目尤其要限制预取:预测过度会抢占真正可见瓦片的带宽和解码预算。
十、内存预算不能只数 GameObject

运行时常见的“内存泄漏”并不一定是对象失去引用,也可能是缓存策略从未设上限。一个瓦片的成本至少包括:
- 下载后的压缩字节;
- 解码后的 CPU 顶点、索引和纹理数据;
- Unity Mesh 的 GPU 缓冲;
- Texture 的 GPU 显存;
- 材质、Renderer、Collider 和 GameObject 开销;
- Addressables、AssetBundle 或原生插件内部缓存。
建议把预算分为两类:
| 预算 | 控制目标 | 典型指标 |
|---|---|---|
| 帧预算 | 避免主线程尖峰 | 遍历耗时、每帧提交数、提交毫秒数 |
| 驻留预算 | 避免内存持续上涨 | GPU 估算、CPU 缓存、瓦片数、纹理数 |
纹理显存可做粗略估算:未压缩 RGBA32 约为 宽 × 高 × 4 字节;完整 Mipmap 链还会增加约三分之一。ASTC、ETC2 等 GPU 压缩格式应按块大小估算,不能用磁盘 PNG/JPEG 文件大小代替显存大小。
10.1 高低水位而非一到阈值就反复回收
例如显存预算 512 MB:超过 460 MB 才启动淘汰,淘汰到 380 MB 停止。候选顺序可以是:
- 当前不可见;
- 不属于父级 fallback;
- 距离上次可见时间最久;
- 重新加载成本较低;
- 内存占用较大。
这比单纯 LRU 更稳,因为某些父级低模虽然很久没直接显示,却是防止空洞的重要回退资源。
十一、坐标精度:城市尺度下必须考虑浮点原点
GIS 坐标通常数值很大,而 Unity 的 Transform 以单精度浮点为主。直接把 ECEF 米制坐标或投影坐标写入 Transform,在远离原点后会出现顶点抖动、相机不稳定和包围体判断误差。
常见方案是:
- 调度与地理计算保留
double; - 以相机附近的地理位置建立局部坐标系;
- 渲染提交前转换为相对浮动原点的
float; - 原点平移时统一更新瓦片根节点,而不是逐顶点修改 Mesh;
- 包围体缓存要明确保存在哪个坐标空间,避免新旧原点混用。
在 XR 中,原点移动还要与 Tracking Origin、玩家 Rig 和物理系统协调。不要在头显运动的同一层级直接硬改相机 Transform;更稳妥的是移动地理世界根节点或 XR Origin 的上层容器。
参考资料
- OGC, 3D Tiles Specification 1.1:https://docs.ogc.org/cs/22-025r4/22-025r4.html
- Cesium Native, 3D Tiles Selection Algorithm Details:https://cesium.com/learn/cesium-native/ref-doc/selection-algorithm-details.html
- Unity Addressables, Load assets:https://docs.unity3d.com/Packages/com.unity.addressables%402.7/manual/load-assets.html
- Unity Addressables, Asynchronous operation handles:https://docs.unity3d.com/Packages/com.unity.addressables%402.0/manual/AddressableAssetsAsyncOperationHandle.html
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_41140324/article/details/166494701




