EQ-雪梨蛋花汤头像
关注
【Unity】 GIS 三维瓦片运行时调度:3D Tiles、屏幕空间误差与异步加载架构封面图

【Unity】 GIS 三维瓦片运行时调度:3D Tiles、屏幕空间误差与异步加载架构

【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 输入中按压/释放阈值的思路一致,目的都是避免临界抖动。

五、每帧遍历:先裁剪,再计算误差

请添加图片描述

一次标准遍历可以按以下顺序进行:

  1. 从根节点进入;
  2. 用包围体做视锥裁剪;
  3. 对可见节点计算距离与 SSE;
  4. SSE 达标则选择当前节点;
  5. SSE 超标且存在子节点则继续向下;
  6. 为缺失内容产生请求,但不在遍历函数中同步加载;
  7. 生成本帧期望渲染集合和候选卸载集合。
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:父级本来就要与子级叠加,不能套用同一隐藏规则。

六、视锥裁剪不是完整答案

视锥裁剪可以排除相机背后的瓦片,但它不知道建筑是否被另一栋建筑完全遮挡,也不知道一个只占几像素的远处节点是否值得请求。移动端项目常见的合理顺序是:

  1. 层级包围体裁剪:最便宜,必须有;
  2. SSE 选择:控制几何与纹理细节;
  3. 地平线或地球遮挡:地球尺度场景才需要;
  4. 遮挡剔除:收益取决于城市密度与相机路径;
  5. 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 停止。候选顺序可以是:

  1. 当前不可见;
  2. 不属于父级 fallback;
  3. 距离上次可见时间最久;
  4. 重新加载成本较低;
  5. 内存占用较大。

这比单纯 LRU 更稳,因为某些父级低模虽然很久没直接显示,却是防止空洞的重要回退资源。

十一、坐标精度:城市尺度下必须考虑浮点原点

GIS 坐标通常数值很大,而 Unity 的 Transform 以单精度浮点为主。直接把 ECEF 米制坐标或投影坐标写入 Transform,在远离原点后会出现顶点抖动、相机不稳定和包围体判断误差。

常见方案是:

  • 调度与地理计算保留 double;
  • 以相机附近的地理位置建立局部坐标系;
  • 渲染提交前转换为相对浮动原点的 float;
  • 原点平移时统一更新瓦片根节点,而不是逐顶点修改 Mesh;
  • 包围体缓存要明确保存在哪个坐标空间,避免新旧原点混用。

在 XR 中,原点移动还要与 Tracking Origin、玩家 Rig 和物理系统协调。不要在头显运动的同一层级直接硬改相机 Transform;更稳妥的是移动地理世界根节点或 XR Origin 的上层容器。


参考资料

  1. OGC, 3D Tiles Specification 1.1:https://docs.ogc.org/cs/22-025r4/22-025r4.html
  2. Cesium Native, 3D Tiles Selection Algorithm Details:https://cesium.com/learn/cesium-native/ref-doc/selection-algorithm-details.html
  3. Unity Addressables, Load assets:https://docs.unity3d.com/Packages/com.unity.addressables%402.7/manual/load-assets.html
  4. 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

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--