最近把一个 3DGS 展厅 Demo 从“只看一个模型”改成“连续切三个模型”时,我才发现,真正麻烦的不是 loadGSNode() 能不能跑通,而是第二次、第三次切换以后,场景是不是还保持干净。

单模型 Demo 很容易给人一种错觉:插件加载成功、节点出现、画面能转动,这件事就结束了。实际产品里用户会在大厅、样板间、展柜模型之间来回切,甚至连续点两三次。如果每次都重新准备 Scene,又不处理上一轮节点,加载路径会越来越难判断;如果多个异步加载同时回来,最后显示的还可能不是用户最后一次选择的模型。

这篇我把问题收窄到一个很具体的工程目标:GSModelSwitchLab 只保留一个 Scene,GSPlugin 只准备一次,每次模型切换都带 token;新模型确认有效后替换旧节点,中途过期的加载结果立即释放。运行记录统一使用会话 gs_switch_20261001_07,最终活动模型是 lobby_v3.glb,第三次切换完成后状态为 SWITCHED,旧节点累计销毁 2 个,本次加载耗时 186 ms。

一、第一次暴露问题的不是加载失败,而是“切换太成功”

最开始的页面只有三个按钮:大厅、庭院、会议室。每个按钮都能把对应 3DGS 模型显示出来,看起来没问题。真正连续点几次后,我在日志里看到两个现象。

第一个现象是 Scene 初始化日志重复出现。页面并没有退出,只是换模型,却不断创建新的场景上下文。单次操作感觉不到明显差异,但调试时很难判断当前屏幕究竟属于哪一次 Scene。第二个现象更明显:我快速点击“庭院→大厅”,庭院模型加载慢一点,反而在大厅之后返回,于是页面最后显示成庭院。UI 上“当前模型”写着大厅,画面却不是大厅。

这类问题不是 3DGS 特有,本质上都是“异步结果晚到覆盖新状态”。只是 3D 模型加载比普通 JSON 请求更重,结果对象本身又是场景节点,过期结果不能只是 return,还要考虑它已经创建出来以后怎么处理。

官方当前文档里,spatialRender 用于 3DGS 数据渲染;使用 GS 相关能力前需要通过 RenderContext 加载 GSPlugin.PLUGIN_ID,再通过 GSPlugin.loadGSNode() 把模型节点加载进 Scene。GSNode 继承自 ArkGraphics 3D 的节点体系。这个调用顺序决定了我的封装边界:插件和 Scene 放到长生命周期 Runtime,模型节点才属于“每次切换”的短生命周期资源。

二、先把插件和 Scene 从页面点击事件里拿出去

我没有把 loadPlugin() 和 Scene.load() 写在每次按钮点击里,而是先做了一个 GSSceneRuntime。它只解决一件事:保证插件和场景准备过程最多执行一条共享 Promise。

当前代码解决的问题,是连续点击时避免重复准备底层渲染环境,同时让后续模型切换都落到同一个 Scene 上。

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

export class GSSceneRuntime {
  private scene: Scene | null = null
  private preparing: Promise<Scene> | null = null

  async ensureScene(): Promise<Scene> {
    if (this.scene) {
      return this.scene
    }
    if (this.preparing) {
      return this.preparing
    }

    this.preparing = (async () => {
      const context: RenderContext | null = Scene.getDefaultRenderContext()
      if (!context) {
        throw new Error('RENDER_CONTEXT_UNAVAILABLE')
      }
      context.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID)
      const scene = await Scene.load()
      this.scene = scene
      return scene
    })()

    try {
      return await this.preparing
    } finally {
      this.preparing = null
    }
  }
}

这里我刻意没有用一个简单的 pluginLoaded: boolean 解决并发。原因很实际:布尔值只能描述“完成/没完成”,描述不了“正在准备”。两个点击在第一轮 await 完成前进入时,都可能看到 false,然后各自执行初始化。共享 Promise 才能把“正在初始化”变成一个可复用状态。

scene 一旦得到就直接返回,所以 GSModelSwitchLab 的运行页会显示 Scene Reuse = true。正式项目还要补充设备能力检查、插件加载失败后的错误码映射,以及页面跨 Ability 时 Runtime 到底放在哪个生命周期里;Demo 为了把问题讲清楚,只限定在单 Ability、单 3D 预览页。

三、模型切换真正需要的是 token,而不是 loading 布尔值

项目目录最终很简单:pages/GSModelSwitchPage.ets 管页面,render/GSSceneRuntime.ets 管 Scene,render/ModelSwitchCoordinator.ets 管切换顺序,model/ModelEntry.ets 管模型 URI。这里最关键的是 ModelSwitchCoordinator。

一开始我也写过 isLoading。后来发现它只会把第二次点击挡掉,用户快速切模型时体验反而不对。更合理的策略不是“加载中禁止点击”,而是允许新请求产生,并让旧请求在回来时知道自己已经过期。

下面这段代码解决的是“后发请求已经成为最新意图,但先发请求稍后才返回”的覆盖问题。

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

export class ModelSwitchCoordinator {
  private token: number = 0
  private activeNode: spatialRender.GSNode | null = null
  private activeUri: string = ''
  destroyedNodes: number = 0

  constructor(private runtime: GSSceneRuntime) {}

  async switchModel(uri: string): Promise<boolean> {
    const currentToken = ++this.token
    const scene: Scene = await this.runtime.ensureScene()
    const next = await spatialRender.GSPlugin.loadGSNode(
      scene,
      { uri, offset: 0 },
      scene.root
    )

    if (currentToken !== this.token) {
      next.destroy()
      this.destroyedNodes++
      return false
    }

    const old = this.activeNode
    this.activeNode = next
    this.activeUri = uri

    if (old) {
      old.destroy()
      this.destroyedNodes++
    }
    return true
  }
}

逻辑顺序不能反。新节点没有加载成功以前,我不会先销毁旧节点,否则加载失败时页面会直接空掉。只有 next 已经拿到并且 token 仍然有效,才交换 activeNode,随后释放 old。这样页面至少一直保留上一份可用内容。

还有一个容易忽略的分支:过期的新节点也必须处理。很多异步代码写成 if (stale) return 就结束了,但这里 next 已经是创建完成的场景对象,不再使用时需要进入资源释放路径。ArkGraphics 3D 的资源对象提供销毁能力;实际项目应以当前 SDK 中节点类型公开的资源释放接口为准,不要把“JS 变量被覆盖”理解成“底层图形资源已经释放”。

四、为什么我又加了一层串行队列

token 能防止旧结果覆盖新结果,却不代表所有加载都应该无上限并行。3D 模型不是一个几十 KB 的配置文件,连续三次点击如果真的同时发起三次完整加载,前两次最后虽然会被丢弃,计算和 IO 依然发生了。

所以第二版我又加了一个很轻的串行链。它不是为了改变“最后一次选择优先”的规则,而是为了让实际加载数量更可控。

这段代码解决的是高频点击时的重复重任务问题,同时保留最后一次模型意图。

export class SwitchQueue {
  private latestUri: string = ''
  private running: boolean = false

  constructor(private coordinator: ModelSwitchCoordinator) {}

  request(uri: string): void {
    this.latestUri = uri
    if (!this.running) {
      this.drain()
    }
  }

  private async drain(): Promise<void> {
    this.running = true
    try {
      while (this.latestUri) {
        const uri = this.latestUri
        this.latestUri = ''
        await this.coordinator.switchModel(uri)
      }
    } finally {
      this.running = false
    }
  }
}

这里没有维护传统 FIFO。用户从 A 点到 B,又立刻点 C,我并不关心 B 一定加载出来一次,我关心的是 A 当前可看、C 最终出现。latestUri 相当于把等待区压成一个槽位,中间意图可以被新意图覆盖。

这和之前做过的 Tiled 3DGS 瓦片回压不是一个问题。瓦片回压解决的是同一个场景里大量 tile 请求的并发量;这里解决的是完整模型切换时的“用户意图压缩”和节点生命周期。工程上看似都叫队列,控制对象完全不同。

五、退出页面时,我只释放短生命周期资源

Scene 复用以后,页面退出要不要把 Scene 一起销毁,不能靠习惯决定。当前 Demo 的目标是同一功能页里反复切模型,所以活动 GS 节点属于页面资源;Scene Runtime 则属于更长的预览会话。如果用户只是进入模型详情再返回,我希望 Scene 还能继续用。

下面这段代码解决的是页面销毁时活动模型仍持有资源的问题,同时保留 Runtime 的复用边界。

releaseActiveNode(): void {
  if (this.activeNode) {
    this.activeNode.destroy()
    this.activeNode = null
    this.activeUri = ''
    this.destroyedNodes++
  }
}

aboutToDisappear(): void {
  this.switchQueue.stopAccepting()
  this.coordinator.releaseActiveNode()
}

正式工程会再区分“页面暂时不可见”“功能模块真正退出”“Ability 销毁”三个层级。尤其是 3D 页面,如果仅因为一次导航覆盖就把插件、Scene 和缓存全部拆掉,回来时重建成本可能比保留一段会话更高;反过来,如果应用已经进入不可恢复的结束路径,还继续把节点和场景挂在全局单例里,也会让资源边界失控。

我的做法是先画一张所有权图:谁创建、谁替换、谁最终释放。只要这三件事说不清,3D 模型切换代码迟早会出现“偶现”。

六、最后跑出来的数据,重点不是 186 ms

GSModelSwitchLab 最终运行的是第三次切换。会话 ID 为 gs_switch_20261001_07,插件已加载,Scene 复用为 true,活动模型 lobby_v3.glb,状态流是 INIT → PLUGIN_READY → LOADING → SWAPPING → SWITCHED。Switch Count = 3,Destroyed Nodes = 2,本轮记录的 Load Cost = 186 ms。

我更在意的不是这次机器上测到 186 ms,而是几次切换之后状态仍然能解释:为什么插件没重复初始化,为什么 Scene 没换,为什么旧节点数量是 2,为什么屏幕上的活动模型和最后一次点击一致。性能数字换设备一定会变,所有权和状态链不应该跟着变。

实际产品还要继续补三类边界。第一类是模型 URI 不可用,要保留旧模型并把错误落到 UI,而不是清空预览。第二类是应用进入后台后渲染上下文是否仍可直接复用,需要跟随当前图形生命周期重新验证。第三类是模型本身包含额外材质、图片或业务侧缓存时,节点销毁不代表你的所有自建缓存都自动清理,仍要按各资源真实所有者释放。

这次改造以后,我对 3DGS 预览页的判断标准也变了。能把一个模型显示出来只是最小能力;真正接近工程可用,是用户连续做三次、十次切换以后,画面、状态、资源和日志仍然对得上。

七、把 Scene 做成长生命周期,不等于做成永久单例

这个 Demo 跑顺以后,我又把 GSSceneRuntime 从页面里继续向外抽了一层,但没有直接塞进一个永不销毁的全局单例。原因是 3D 场景有明显的会话边界:同一个模型预览功能里,列表页进入详情、详情再换模型,这些动作共享 Scene 很合理;用户已经退出整个 3D 功能模块,或者应用的渲染上下文被系统重建,再保留旧 Scene 就未必成立。

我最后给 Runtime 加了一个很简单的引用计数思路。进入 3D 预览模块时 retain(),真正离开模块时 release();计数归零以后,停止接受新的模型切换请求,再统一清理当前活动节点和业务自己创建的缓存。这样做的价值不是“写了一个更高级的管理器”,而是把资源结束的时机从某个页面的 aboutToDisappear() 中拿出来。页面不可见和功能会话结束是两回事,尤其在多页 Navigation、折叠屏双栏或弹层覆盖场景里,这个区别很明显。

还有一个细节我会单独记录:Scene 复用只负责避免重复创建,不意味着所有相机、后处理参数都要永久沿用。比如用户从大厅模型切到小型展柜模型,上一模型使用的相机位置、曝光、背景环境可能并不适合下一模型。我会把“Scene 本体复用”和“每个模型的视图配置”拆开。模型切换成功后,根据 ModelEntry 恢复相机预设;切换失败则保持旧节点和旧相机,不让 UI 出现模型没换成功、视角却先跳走的半完成状态。

这也是为什么我不喜欢把所有东西都放进一个 switchModel():插件、Scene、节点、相机、UI 状态的生命周期长度并不一样。代码看上去少几个类,运行时反而更难解释。

八、我的验收方式不是“点一下能换”,而是连续制造冲突

最后验收时我没有按 A、B、C 慢慢点,而是专门做几组容易出问题的操作。第一组是 A 还没完成就点 B,再马上点 C,最终必须只显示 C,日志里允许出现过期节点被创建,但它必须立刻进入销毁路径。第二组是同一个模型连续点三次,最终活动节点只能有一个,切换计数可以增长,但不能让同 URI 的节点在 Scene 里层层叠加。第三组是模型路径故意写错,加载失败时旧模型必须继续可见,页面状态回到可操作,而不是停在 LOADING。

第四组是连续进入和退出预览页。每一轮退出都检查活动节点是否归零,再进入时检查插件和 Scene 是否按预期复用。第五组才是正常的三模型切换,用来记录 Switch Count、Destroyed Nodes 和 Load Cost。只有前四组都通过,第五组的 186 ms 才值得看。否则单纯比较加载耗时,很容易在一个生命周期有问题的实现上做“性能优化”。

我还会把日志字段固定下来:session、token、uri、state、destroyedNodes、loadCost。这样同事拿到一段日志,不需要先读 UI 就能把切换链还原出来。对于 3D 场景,能把状态还原出来比“偶现时再连机器看一眼”可靠得多。

九、参考资料与版本说明

本文按 2026 年 9 月公开的 HarmonyOS 文档核对 API 行为。Spatial Recon Kit 的 spatialRender 提供 3DGS 渲染能力,使用 GS 能力前应加载 GSPlugin.PLUGIN_ID,并可通过 GSPlugin.loadGSNode() 加载 GS 节点;ArkGraphics 3D 的 SceneResource 文档说明资源销毁后不能继续访问。实际项目请以当前 DevEco Studio 配套 SDK 的类型声明和设备支持范围为准。

  • Spatial Recon Kit / spatialRender:https://developer.huawei.com/consumer/en/doc/harmonyos-references/spatial-recon-spatialrender
  • ArkGraphics 3D:https://developer.huawei.com/consumer/cn/sdk/arkgraphics-3d/
Logo

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

更多推荐