这次没有继续写“3DGS 怎么显示出来”,而是把问题往真实工程里再推一步:当大场景被切成很多 tile,镜头持续移动,渲染器会不断提出新的分片请求。真正难处理的不是 loadTiledGSNode(),而是请求重复、文件未落盘就通知 ready、页面退出后回调还在继续,以及 UI 状态和渲染状态互相打架。

我给这个小工程起名叫 TiledGSFieldLab,页面是 LargeScenePage。为了方便复现,本文把一次运行固定成 scene_20261001_01。截图里看到的状态也都用这一组数据:STREAMING、24 个 tile 请求、18 个已就绪、6 个等待中,当前可用进度 75%,相机 yaw 为 32.4°。

官方当前的 spatialRender 模块用于 3DGS 数据渲染。API 26 的 TiledGSNode 进一步提供 setCamera()、setTileRequestCallback() 和 notifyTileReady(),适合把大场景切片后的“渲染器需要什么、应用准备什么、准备好以后再通知渲染器”连成一条链路。这里最关键的一点是:回调给你的不是“马上能画”的文件,而是一批“渲染器现在想要”的 tile。

一、我先把问题从“加载模型”改成“管理一条流”

最开始的版本很直接:场景加载完成以后注册回调,收到 tile 就去拿文件,拿完调用 notifyTileReady()。

Demo 能跑,镜头不动时也没什么问题。但一旦我连续转动镜头,HiLog 很快开始出现同一个 URI 多次进入下载队列的情况。网络快时只是多花流量,网络稍慢一点,后面的问题就一起出现了:同一个 tile 被重复写入、ready 次数大于真实文件数、页面已经返回上一层,异步任务仍在回写页面状态。

这类问题如果只盯着 GSNode,很容易误判成“3DGS 渲染不稳定”。实际用下来,更像是一个典型的流式资源管理问题。

我最后把一次大场景加载拆成四个状态:

  • IDLE:页面刚进入,还没有可用场景;
  • SCENE_READY:Scene、Camera、TiledGSNode 已经创建;
  • STREAMING:渲染器开始请求 tile;
  • STABLE:当前一轮没有等待中的 tile,画面进入相对稳定状态。

注意,STABLE 不是“以后再也不会请求文件”。镜头继续移动以后,新的 tile 仍然可能出现,所以这个状态只描述当前窗口,不描述整个模型已经永久加载完成。

二、先把 Scene、Camera 和 TiledGSNode 的关系搭稳

当前这段代码解决的问题,是不要在页面里到处散落 Scene、Camera、GSNode 的初始化。只有三者关系建立以后,后面的 tile 回调才有意义。

import { Scene, RenderContext, Camera, Node } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';

private scene: Scene | null = null;
private camera: Camera | null = null;
private tiledGSNode: spatialRender.TiledGSNode | null = null;

private async prepareScene(): Promise<void> {
  const renderContext: RenderContext | null = Scene.getDefaultRenderContext();
  if (!renderContext) {
    throw new Error('RenderContext unavailable');
  }

  renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);

  const scene = await Scene.load();
  const root = scene.root as Node;
  const factory = scene.getResourceFactory();

  const camera = await factory.createCamera({
    name: 'streamCam',
    path: root.path
  });

  const node = await spatialRender.GSPlugin.loadTiledGSNode(
    scene,
    {
      uri: 'file:///data/storage/el2/base/files/campus-lod/campus.scene.json'
    },
    root
  );

  node.setCamera(camera);

  this.scene = scene;
  this.camera = camera;
  this.tiledGSNode = node;
  this.streamState = 'SCENE_READY';
}

这里有两个地方我会特别注意。

第一,插件加载不能省。官方文档对 GSPlugin.PLUGIN_ID 的要求很明确,在使用相关 3DGS 能力前要先加载插件。第二,setCamera() 不是为了“让页面多一个相机变量”,而是告诉 TiledGSNode:后续 tile 选择要跟着哪一个相机变化。

也就是说,镜头位置变化以后,真正决定“下一批需要哪些 tile”的主体是渲染侧。应用侧要做的是响应这批请求,不要擅自把所有 tile 一次性塞进内存。

当前 API 26 的 TiledGS 相关接口是 Stage 模型能力,所以正式项目里不要把这套封装想当然地搬到不满足模型约束的模块里。

三、回调里不直接下载,先做 URI 去重

问题最明显的一次,我快速左右拖动视角,日志里 24 次请求并不等于 24 个不同文件。一个 tile 在短时间里可能再次进入需求列表。

这段代码解决的就是“同一 URI 在上一次请求还没结束时,又被第二次塞进队列”。

private pendingUris: Set<string> = new Set();
private requestedCount: number = 0;
private readyCount: number = 0;

private bindTileRequests(): void {
  const node = this.tiledGSNode;
  if (!node) {
    return;
  }

  node.setTileRequestCallback((tiles: spatialRender.GSTile[]) => {
    if (!tiles || tiles.length === 0 || this.disposed) {
      return;
    }

    this.streamState = 'STREAMING';

    for (const tile of tiles) {
      if (this.pendingUris.has(tile.uri)) {
        continue;
      }

      this.pendingUris.add(tile.uri);
      this.requestedCount += 1;
      this.loadTile(tile);
    }
  });
}

这里没有使用数组,而是 Set<string>。原因很简单:我要表达的是“某个 URI 现在是否已经处于处理中”,不是维护严格的顺序。

这个改动以后,requestedCount 才开始有工程意义。它记录的是进入本地加载流程的唯一请求数量,而不是 callback 触发次数。

正式项目还可以再加一层已完成缓存。例如本地文件已经存在并通过校验,就不必再次走网络。本文 Demo 为了把链路讲清楚,只保留“处理中去重”。

四、文件真的可读以后,再通知渲染器

我第二次踩到的问题更隐蔽。

最早的代码是发起下载以后就调用 notifyTileReady()。从业务视角看,好像“我已经开始准备这个 tile 了”;但从渲染器视角看,ready 的含义不是“开始准备”,而是对应 URI 的文件已经可访问、可读取。

所以这段代码解决的是 ready 时机。

private async loadTile(tile: spatialRender.GSTile): Promise<void> {
  try {
    await this.tileCache.ensureReadable(tile.uri);

    if (this.disposed || !this.tiledGSNode) {
      return;
    }

    this.tiledGSNode.notifyTileReady(tile);

    this.readyCount += 1;
    this.pendingUris.delete(tile.uri);

    if (this.pendingUris.size === 0) {
      this.streamState = 'STABLE';
    }
  } catch (error) {
    this.pendingUris.delete(tile.uri);
    console.error(`Tile failed: ${tile.uri}, ${JSON.stringify(error)}`);
  }
}

tileCache.ensureReadable() 是我自己的工程封装,不是 Spatial Recon Kit 的 API。它只负责一件事:确保 tile.uri 对应的数据已经准备到渲染器能够读取的位置,并且在 Promise resolve 之前完成必要的落盘。

这层抽象很重要。否则网络请求、文件写入、重试、校验全部塞进 LargeScenePage.ets,页面会迅速变成第二个下载管理器。

官方对 notifyTileReady() 的描述还有一个很实用的边界:如果相机已经移开,渲染器不再需要这个 tile,这次通知不会继续产生有效加载动作。这个行为能兜住“请求过程中视角已经改变”的情况,但它并不能替你完成网络请求去重,所以应用侧的 pending 集合仍然有价值。

五、页面退出时,我更关心“回调还会不会回来”

大场景页面最容易被忽略的不是首屏,而是退出。

用户返回上一页时,如果 callback 仍然持有页面逻辑,后面某个异步下载完成以后还可能继续更新 readyCount、streamState,甚至继续打印已经没有意义的 UI 日志。

所以我把退出逻辑明确做成一个收口动作。

private disposed: boolean = false;

aboutToDisappear(): void {
  this.disposed = true;

  if (this.tiledGSNode) {
    this.tiledGSNode.setTileRequestCallback(null);
    this.tiledGSNode.visible = false;
  }

  this.pendingUris.clear();
  this.streamState = 'IDLE';
}

这里的重点不是“清空 Set”三个字,而是先断掉新的 tile 请求来源,再让已经在途的异步逻辑通过 disposed 自己失效。

我没有在 Demo 里虚构一个不存在的 destroyTiledGSNode()。Scene、资源对象和更底层资源的释放策略要结合实际工程持有关系处理。当前页面能明确做的,是取消 callback、停止继续写 UI、清理页面级状态。

如果你的资源管理层是单例,还要继续处理“页面销毁不等于缓存销毁”这个差异。页面状态和资源缓存最好不要用同一个生命周期。

六、调试时不要只看画面,数值要能互相对上

做到这里以后,我才开始给页面加运行面板。

这次固定运行数据是:

  • Task ID:scene_20261001_01
  • State:STREAMING
  • Progress:75%
  • Tiles:18 / 24
  • Pending:6
  • Camera Yaw:32.4°

我把“进度”定义为当前已 ready 的 tile 数除以当前唯一请求数,也就是 18 / 24。它不是整个 campus 场景的全局完成率,更不是 3DGS 重建算法的训练进度。

这点如果不写清楚,75% 很容易制造一种“整个模型还有 25% 没加载完”的错觉。

DevEco 这一张我更在意四块信息能不能互相验证:左边是 TiledGSFieldLab 工程目录,中间能看到 LargeScenePage.ets 里的请求回调,右侧模拟器是 Campus GS Stream,底部 HiLog 同时打印 scene_20261001_01、SCENE_READY -> STREAMING、24、18、6。

只要其中一个数对不上,我就会先怀疑状态统计,而不是立刻怀疑 Spatial Recon Kit。

七、手机上的 75%,是“当前可用画面”而不是假完成

实际运行到截图这一刻,前景已经有足够 tile 可以显示,用户能看到校园大场景,但后台仍有 6 个 tile 在补齐。

这也是我最终保留 STREAMING 状态的原因。

如果为了 UI 看起来“成功”,18 个 tile ready 时就切成 COMPLETED,后面相机继续转动又重新出现请求,状态机会非常别扭。流式场景更适合描述当前阶段,而不是追求一个永久完成的终点。

我在正式项目里还会继续补三件事。

第一是失败退避。同一个 URI 连续失败不能无限重试,至少要记录失败次数和下一次允许重试的时间。

第二是缓存预算。大场景 tile 不能只进不出,内存缓存、本地磁盘缓存都要有上限,最好按最近使用和文件体积做淘汰。

第三是相机快速移动时的优先级。距离当前视锥更重要的 tile 应该优先,而不是严格按首次进入队列的时间处理。

这些都属于资源调度,不应该继续堆在 ArkUI 页面里。

八、这次真正解决的不是“会不会调用 API”

回头看这次 Demo,核心 API 其实不多:加载插件、创建场景、加载 TiledGSNode、绑定 Camera、注册 tile callback、文件准备完成后 notifyTileReady()。

真正把 Demo 和工程实现拉开差距的,是中间那层状态管理:

渲染器发出需求,应用做请求去重,资源层保证文件可读,页面生命周期决定结果还能不能回写,最后 UI 只展示当前真实状态。

这条链路理顺以后,再往后加预取、并发限制、离线缓存、失败重试都比较自然。

如果一开始就把每一次 tile callback 当成“下载一次文件”,短场景可能看不出问题,大场景一转相机,网络、磁盘、UI 生命周期会一起把问题暴露出来。

所以这次我最后留下的不是一段更长的 3DGS 示例代码,而是一条更清楚的加载闭环:

Camera 变化 → Renderer 请求 tile → 应用去重与准备资源 → 文件可读 → notifyTileReady → Renderer 接管显示。

这才是 TiledGSNode 在大场景里更值得花时间处理的部分。

参考资料

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐