这次我没有把 3DGS 当成“放一个模型到页面上”的普通 3D 展示,而是把问题收缩到真正影响端侧体验的一段链路:插件加载、场景装载、相机驱动、分块请求、状态可视化和调试闭环。Demo 项目名统一为 GaussField,场景文件为 courtyard.scene.json。

做 3DGS 相关 Demo 时,很容易出现一种错觉:模型文件能打开,说明接入已经完成。真正把它放到手机上跑一段时间之后,会发现问题并不在“能不能显示”,而在“怎么稳定地显示”。一个小场景可以整包加载,大场景就要考虑可见区域、瓦片、相机变化、资源占用以及请求节奏。只要其中一环没有做状态收口,最终看到的现象往往很像渲染问题,实际却可能是加载链路没有建立完整。

HarmonyOS 的 Spatial Recon Kit 把 3DGS 相关的重建、渲染和编辑能力放进了统一能力域;在渲染侧,spatialRender 可以加载普通 3DGS 节点,也可以在较新的能力版本中加载 Tiled 3DGS 场景。后者的价值不是“换一种文件格式”,而是允许渲染器围绕相机视口按需请求瓦片,让大场景不必一次把全部数据压进内存。

这篇文章记录的就是我把一个院落 3DGS 场景接到 HarmonyOS 7 Demo 中的过程。最终目标很具体:进入页面后,状态从 READY 走到 INTERACTIVE;场景文件固定为 courtyard.scene.json;调试页能够看到 24 / 96 的瓦片请求进度、当前 58 FPS,以及最近一次请求到的 tile_024.sog。

一、先把“显示模型”和“端侧场景系统”分开

我最开始做的版本只有两个动作:初始化 Scene,然后加载一个 3DGS 模型。这个版本看起来很干净,但它隐藏了一个问题——UI 完全不知道底层现在处于什么状态。

页面出现了模型,不等于链路没有问题。比如插件尚未完成初始化时就开始加载资源、页面退出后仍有异步结果回调、相机对象更换后分块节点还绑定着旧相机、路径错误时 UI 还保持“加载中”,这些问题都不会在第一屏立刻暴露。

所以我先给 GaussField 定了四个业务状态:

  • READY:页面已创建,尚未开始场景装载;
  • LOADING:插件和场景正在建立;
  • INTERACTIVE:场景可交互,相机已能驱动瓦片选择;
  • ERROR:初始化或资源加载失败,需要把错误原因暴露给页面。

这里有个工程上的取舍:这些状态不是 Spatial Recon Kit 强制提供的枚举,而是 Demo 自己的业务状态。这样做的好处是把底层能力和页面行为隔开。以后无论普通 GSNode 还是 TiledGSNode,页面只关心“现在能不能交互”。

这段代码解决什么问题:把插件加载、场景加载与 UI 状态放进同一条可观察链路。

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

type SceneState = 'READY' | 'LOADING' | 'INTERACTIVE' | 'ERROR'

@Entry
@Component
struct Index {
  @State sceneState: SceneState = 'READY'
  @State errorMessage: string = ''

  private renderContext: RenderContext | null = null

  async prepareRenderContext(): Promise<void> {
    this.sceneState = 'LOADING'
    this.renderContext = Scene.getDefaultRenderContext()

    if (!this.renderContext) {
      this.sceneState = 'ERROR'
      this.errorMessage = 'RenderContext unavailable'
      return
    }

    try {
      this.renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID)
      console.info('[GS] plugin loaded')
    } catch (err) {
      this.sceneState = 'ERROR'
      this.errorMessage = `plugin load failed: ${JSON.stringify(err)}`
    }
  }
}

这段代码没有急着创建模型。原因是插件加载是后续 3DGS 节点加载的前置条件。把这一步拆出来之后,日志里能够明确看到 [GS] plugin loaded,UI 也能在失败时结束“无限转圈”。

实际项目里还要注意一个点:不要把 loadPlugin() 放进会重复触发的普通构建逻辑里。页面状态变化会带来重新构建,但渲染插件的初始化应该由清晰的生命周期入口控制,而不是跟着 UI 重建次数走。

二、小场景能整包,大场景不要硬扛

普通 3DGS 模型适合体量可控、资源边界明确的场景。它的使用思路很直:准备 Scene,调用 GSPlugin.loadGSNode(),然后把节点挂到场景树里。这样的代码容易理解,也适合验证模型是否能正确导入。

但我这次更关心的是院落外扩以后怎么办。场景一旦从“一个房间”变成“院落 + 街区”,最直接的问题就是资源规模。把全量高斯点一次性加载到端侧,不仅首屏等待时间会增加,内存峰值也会跟着放大。更麻烦的是,大多数时候用户只看当前视口附近,远处的数据即便已经加载也没有形成等比例的体验收益。

Tiled 3DGS 正好适合把这个问题拆开。场景按空间切成多个瓦片,渲染侧根据相机驱动瓦片选择,再把需要的资源交给应用处理。官方能力中,TiledGSNode 需要通过 setCamera() 绑定相机,并可以通过 setTileRequestCallback() 拿到渲染器当前请求的 GSTile 列表。

我在 Demo 里把总瓦片数记为 96,不是说所有项目都应该切成 96 份,而是为了让调试状态可视化。当前截图中请求到 24 / 96,代表视口变化后已经发生了 24 个瓦片请求。

这段代码解决什么问题:加载分块 3DGS 场景,并把相机与瓦片请求链路真正连起来。

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

class TiledSceneController {
  private tiledNode?: spatialRender.TiledGSNode
  private camera?: Camera

  async load(scene: Scene, root: Node, camera: Camera): Promise<void> {
    this.camera = camera

    const params: spatialRender.TiledGSImportSettings = {
      uri: 'file:///data/storage/el2/base/files/courtyard/courtyard.scene.json'
    }

    this.tiledNode = await spatialRender.GSPlugin.loadTiledGSNode(
      scene,
      params,
      root
    )

    this.tiledNode.setCamera(camera)
    this.tiledNode.setTileRequestCallback((tiles: spatialRender.GSTile[]) => {
      for (const tile of tiles) {
        console.info(`[GS] requested tile=${tile.uri}`)
      }
    })
  }
}

真正容易写错的地方不是 API 名字,而是调用顺序。TiledGSNode 创建出来以后,如果没有绑定能够驱动选择的 Camera,那么“分块场景已经加载”与“瓦片会跟着视口请求”是两件事。也就是说,看到节点存在,不能直接判断按需加载已经工作。

我会把相机绑定放在节点创建完成后立刻做,并把瓦片请求回调当成验收点。只要移动视角后日志里没有任何新 URI,就应该先检查 Camera 和回调链路,而不是先怀疑模型质量。

上图就是我希望保留的调试现场:左侧工程目录、中间加载代码、右侧模拟器和底部日志同时出现。页面显示 INTERACTIVE,场景文件是 courtyard.scene.json,请求瓦片为 24 / 96,当前帧率 58。这些值并不是为了做性能宣传,而是为了让“UI、代码、日志”能够互相对上。

三、瓦片请求不能只打日志,要变成可诊断的数据

只在 HiLog 里打印 URI,调试几分钟还行,真的遇到抖动、白块或视角切换延迟时就不够了。因为你需要知道:请求发生了多少次、哪些瓦片在当前视口内、最近一块是什么、某块是否频繁重复请求、加载时长有没有突然升高。

所以我在 GaussField 里又加了一层 TileDebugStore。它不参与渲染决策,只保存调试信息。这样即使以后切换到别的加载实现,页面上的调试视图也不用跟着重写。

这段代码解决什么问题:把瓦片回调转换成页面可展示、可复盘的诊断状态。

interface TileRequestRecord {
  uri: string
  time: number
  costMs: number
}

@Observed
class TileDebugStore {
  requestedCount: number = 0
  visibleCount: number = 0
  fps: number = 0
  cacheMb: number = 0
  latestTile: string = ''
  records: TileRequestRecord[] = []

  push(uri: string, costMs: number): void {
    this.requestedCount++
    this.latestTile = uri.substring(uri.lastIndexOf('/') + 1)
    this.records.unshift({ uri, time: Date.now(), costMs })
    this.records = this.records.slice(0, 20)
  }
}

这里我没有把“请求次数”和“已完成加载数”混在一起。请求回调描述的是渲染器此刻需要什么,而业务真正完成资源读取、通知瓦片可用,属于下一层。两者如果混成一个数字,出现重试或缓存命中后很难解释。

截图里为了让文章更容易对照,我把调试口径固定为:24 / 96 表示已记录的请求数与场景总瓦片数;18 表示当前视口中参与展示的可见瓦片统计;184 MB 是 Demo 自己记录的本地瓦片缓存占用;最近一次瓦片为 tile_024.sog。这些都属于示例项目指标,并不是 Spatial Recon Kit 自动返回的一组固定 UI 数据。

四、从 READY 到 INTERACTIVE,中间每一步都要有证据

做图形类功能最怕“凭感觉”。页面不黑屏,就觉得加载成功;拖动不卡,就觉得性能没问题。这样的验证方式太粗。

我更愿意把一次场景进入拆成下面几个可观察节点:

READY → LOADING_PLUGIN → LOADING_SCENE → CAMERA_BOUND → TILE_REQUESTING → INTERACTIVE

业务 UI 不一定需要展示这么细,但调试日志最好能保留。这样当问题出现时,可以很快判断停在哪一段。

例如:

  • 停在 LOADING_PLUGIN:优先看插件加载与能力支持;
  • 停在 LOADING_SCENE:检查场景 URI、manifest 和资源可访问性;
  • 已经 CAMERA_BOUND 但没有 TILE_REQUESTING:检查 Camera 是否为当前实际渲染视角;
  • 有瓦片请求但画面缺块:继续看瓦片获取、缓存与 ready 通知流程;
  • 已经 INTERACTIVE 但帧率波动:再去分析可见瓦片数量、视角变化速度和场景规模。

这套判断顺序的价值是把“渲染有问题”拆成多个窄问题。问题越窄,排查越快。

手机页里我没有堆太多调试项,只保留最适合日常判断的四个:状态、文件、瓦片进度和 FPS。这里的 INTERACTIVE 表示页面已经允许用户旋转、缩放和移动观察;24 / 96 与 DevEco 截图保持一致,方便从真机界面反查日志。

五、相机是分块渲染里真正的“需求发生器”

普通列表的懒加载很好理解:滚动到哪里,加载到哪里。Tiled 3DGS 的思路其实类似,只不过“滚动位置”变成了三维相机的视锥和视口。

这也解释了一个我一开始容易忽略的问题:如果应用里有多个 Camera,或者为了截图、预览、自由浏览分别创建了不同相机,那么 setCamera() 绑定哪一个非常关键。绑定的是旧相机,即使用户在 UI 里移动了当前视角,瓦片选择仍可能依据另一个 Camera 的状态。

因此我后来不再把 Camera 当成一个随处可拿的全局变量,而是把它当成 TiledSceneController 的显式依赖。切换主相机时,同步更新 TiledGSNode。这样代码多了一步,但状态关系更清楚。

另外一个边界是页面离开。3D 场景的异步过程可能比页面生命周期更长,尤其是瓦片来自本地文件之外的自定义数据源时。工程里应该保证页面销毁后,不再向已经失效的 UI 状态对象回写;长期驻留的缓存也要有容量上限,而不是把“按需加载”做成“最终还是全量常驻”。

六、用最近一次瓦片请求定位“动一下才加载”的问题

我专门做了一个“分块调试”页,不是为了好看,而是为了处理一种非常典型的现象:场景刚进来时局部清晰,旋转视角后某个方向短时间缺细节,再停一会又恢复。

这种问题只看最终画面很难判断。调试页里我会同时看四个数字:

  • 请求瓦片:24 / 96;
  • 可见瓦片:18;
  • 当前 FPS:58;
  • 缓存占用:184 MB。

再往下看最近一条请求:tile_024.sog。如果视角已经明显变化,但最近瓦片一直不变,优先检查 Camera 和选择逻辑。如果最新瓦片在快速变化,但完成耗时越来越长,就把注意力转向资源读取、缓存和 I/O。

这里红圈标出 tile_024.sog,承担的是“证据”而不是装饰。文章正文写到最近瓦片请求时,读者可以直接对照这一行,而不需要在一堆 UI 元素里猜我要说明什么。

七、不要把“请求瓦片”误写成“网络下载瓦片”

还有一个表达上的坑值得单独说。setTileRequestCallback() 告诉应用的是渲染器希望获得哪些瓦片。瓦片最终来自本地文件、应用缓存、局域网资源还是远端服务,是应用自己的数据链路选择。

所以我在文章和日志里统一使用“请求瓦片”,而不是默认写成“下载瓦片”。否则读者很容易误以为这个能力天然绑定网络方案。

如果业务确实是远端大场景,工程上至少还要补三层:

  1. URI 到缓存键的映射;
  2. 并发请求和取消策略;
  3. 瓦片到达后的可用通知与错误恢复。

视角快速旋转时,旧方向的请求很可能已经失去价值。网络层如果不做取消或优先级控制,就会出现“用户看东边,带宽还在给西边服务”的浪费。这个问题和渲染算法本身无关,却会直接表现成端侧交互延迟。

八、我最后保留的验收清单

这次 Demo 最后没有以“场景能打开”作为完成条件,而是留了一个很短的验收清单。

第一,冷启动进入页面,READY 必须在开始加载后切换为 LOADING,成功后进入 INTERACTIVE,失败必须落到 ERROR,不能无限等待。

第二,场景文件固定为 courtyard.scene.json,切换视角时 setTileRequestCallback() 能产生新的瓦片 URI。

第三,调试页数据必须与日志同源。UI 显示 24 / 96 时,日志里能找到对应的请求累计;最近瓦片为 tile_024.sog 时,最近记录中也能找到它。

第四,连续旋转、缩放、返回页面再进入,不能因为重复初始化让状态叠加。尤其要观察请求数是否异常增长、缓存是否只增不降,以及旧页面回调是否继续打印。

第五,性能数字只用于当前 Demo 的相对观察,不把一次 58 FPS 当成所有设备和场景的固定结论。换模型规模、设备、视口、瓦片切分方式,结果都会变化。

九、这次最大的收获不是“把 3DGS 跑起来”

Spatial Recon Kit 把 3DGS 的能力入口提供出来以后,开发者真正要做的工程工作,是把它嵌进自己的状态系统、资源系统和调试系统里。普通 GSNode 解决的是“把模型放进场景”,TiledGSNode 更进一步,把大场景的资源需求跟相机视口关联起来。

我现在更愿意把 GaussField 看成一个小型场景运行时,而不是模型查看器。页面层有状态,渲染层有节点,相机产生可见需求,瓦片层记录请求,调试页把关键证据暴露出来。这样一旦出现缺块、延迟、异常占用,至少知道应该从哪一层开始查。

对 3DGS 这种看起来很“视觉化”的能力,工程里最重要的反而是把不可见的状态写清楚。画面好看只是结果,能解释这个结果是怎么来的,才算真的接住了能力。

十、普通 GSNode 和 TiledGSNode,我怎么选

如果项目只是展示一件商品、一个人物扫描件或一个小型室内空间,我不会为了“技术更新”强行上分块方案。普通 GSNode 的优点是链路短,资源边界明确,加载完成以后状态也容易理解。场景体量稳定、用户视角范围受控时,它反而更容易做出确定性。

TiledGSNode 更适合另外一类需求:模型规模会继续扩张,用户可以大范围移动,或者资源本身就天然适合按空间组织。这时候关键指标不再只是模型文件多大,而是“当前视口真正需要多少数据”。分块之后,应用可以围绕相机需求做缓存、优先级和释放策略,端侧资源利用会更接近用户正在看的区域。

我通常用三个问题做选择。第一,首屏是否必须很快进入可交互状态;第二,用户有没有可能在大场景中连续移动;第三,模型未来会不会从一个固定资产扩张成可持续更新的空间数据。如果三个答案大多是“是”,分块方案的工程价值就会逐渐超过它增加的复杂度。

还有一点容易忽略:分块并不会自动解决全部性能问题。瓦片过小,会让调度与请求次数增多;瓦片过大,又会让按需加载失去意义。相机快速移动时,资源优先级与取消策略也会直接影响体验。所以 Tiled 不是“性能开关”,而是一套允许应用继续做资源治理的基础机制。

十一、我会怎样构造一次故障验证

正常流程跑通以后,我会故意制造几种错误。把 courtyard.scene.json 改成不存在的路径,看状态能不能从 LOADING 落到 ERROR;临时不调用 setCamera(),看调试页是否出现“场景已建但无瓦片请求”;再把视角快速连续旋转,观察最近请求 URI 是否随视口变化。

这类故障注入很重要,因为图形应用的 happy path 往往太顺。真正上线以后遇到的通常是资源缺失、缓存被清理、页面快速进出、前后台切换或设备负载升高。提前把这些路径走一遍,才能知道日志有没有信息量。

我还会记录一次基线:同一设备、同一场景、同一初始机位下,进入 INTERACTIVE 的时间、首次瓦片请求数量、稳定后的可见瓦片数量和内存区间。以后改了瓦片切分或缓存策略,不需要凭肉眼说“好像更快”,直接跟基线比较即可。

因此这次 Demo 最终留下来的不是一张好看的院落图,而是一套可重复的验证方法:状态能解释,日志能对应,错误能复现,数据能比较。对任何端侧 3D 能力来说,这比单次跑通更有价值。

参考资料

  • 华为开发者:Spatial Recon Kit / spatialRender API
    https://developer.huawei.com/consumer/en/doc/harmonyos-references/spatial-recon-spatialrender
  • 华为开发者:Spatial Recon Kit 术语与 Tiled 3D Gaussian Splatting 说明
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/spatial-recon-glossary
  • 华为开发者:重建三维场景(C/C++)与 Spatial Recon Kit 相关开发指南
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline
Logo

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

更多推荐