云计算百科
云计算领域专业知识百科平台

【共创稿事节】HarmonyOS 7商品展示场景:3DGS 重建一个小体积摆件的完整路径与真机数据

电商商品详情页要一个能 360° 转着看的手办,传统做法是请建模师按实物拉面片、烘焙贴图、出 GLB,单件成本几百到几千,周期按周算。3DGS 端侧重建换了一条路:拍一段环绕视频,端侧跑完出点云,直接挂到 Scene 里渲染。这篇文章记录我们用一个 15cm PVC 手办走完整条链路的真机数据,包括重建耗时波动、点云体量取舍、渲染帧率,以及背景干扰这个最容易被低估的坑。

能力面:从采集到渲染的完整 API 链

Spatial Recon Kit 在 API 26 暴露的核心入口是 spatialRecon(采集与重建)和 spatialRender(加载与渲染)。两者职责分开:前者产出点云文件落盘,后者把文件挂到 ArkGraphics3D 的 Scene 上。一条完整链路是 采集 → 重建 → 落盘 → 加载 GSNode → 挂到 Scene → Component3D 渲染。

先看这条链路的图形化版本,下面每个环节逐段展开。

#mermaid-svg-Ag3p9gvky61FueL4{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ag3p9gvky61FueL4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ag3p9gvky61FueL4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ag3p9gvky61FueL4 .error-icon{fill:#552222;}#mermaid-svg-Ag3p9gvky61FueL4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ag3p9gvky61FueL4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ag3p9gvky61FueL4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ag3p9gvky61FueL4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ag3p9gvky61FueL4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ag3p9gvky61FueL4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ag3p9gvky61FueL4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ag3p9gvky61FueL4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ag3p9gvky61FueL4 .marker.cross{stroke:#333333;}#mermaid-svg-Ag3p9gvky61FueL4 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ag3p9gvky61FueL4 p{margin:0;}#mermaid-svg-Ag3p9gvky61FueL4 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ag3p9gvky61FueL4 .cluster-label text{fill:#333;}#mermaid-svg-Ag3p9gvky61FueL4 .cluster-label span{color:#333;}#mermaid-svg-Ag3p9gvky61FueL4 .cluster-label span p{background-color:transparent;}#mermaid-svg-Ag3p9gvky61FueL4 .label text,#mermaid-svg-Ag3p9gvky61FueL4 span{fill:#333;color:#333;}#mermaid-svg-Ag3p9gvky61FueL4 .node rect,#mermaid-svg-Ag3p9gvky61FueL4 .node circle,#mermaid-svg-Ag3p9gvky61FueL4 .node ellipse,#mermaid-svg-Ag3p9gvky61FueL4 .node polygon,#mermaid-svg-Ag3p9gvky61FueL4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ag3p9gvky61FueL4 .rough-node .label text,#mermaid-svg-Ag3p9gvky61FueL4 .node .label text,#mermaid-svg-Ag3p9gvky61FueL4 .image-shape .label,#mermaid-svg-Ag3p9gvky61FueL4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ag3p9gvky61FueL4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ag3p9gvky61FueL4 .rough-node .label,#mermaid-svg-Ag3p9gvky61FueL4 .node .label,#mermaid-svg-Ag3p9gvky61FueL4 .image-shape .label,#mermaid-svg-Ag3p9gvky61FueL4 .icon-shape .label{text-align:center;}#mermaid-svg-Ag3p9gvky61FueL4 .node.clickable{cursor:pointer;}#mermaid-svg-Ag3p9gvky61FueL4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ag3p9gvky61FueL4 .arrowheadPath{fill:#333333;}#mermaid-svg-Ag3p9gvky61FueL4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ag3p9gvky61FueL4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ag3p9gvky61FueL4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ag3p9gvky61FueL4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ag3p9gvky61FueL4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ag3p9gvky61FueL4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ag3p9gvky61FueL4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ag3p9gvky61FueL4 .cluster text{fill:#333;}#mermaid-svg-Ag3p9gvky61FueL4 .cluster span{color:#333;}#mermaid-svg-Ag3p9gvky61FueL4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ag3p9gvky61FueL4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ag3p9gvky61FueL4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ag3p9gvky61FueL4 .icon-shape,#mermaid-svg-Ag3p9gvky61FueL4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ag3p9gvky61FueL4 .icon-shape p,#mermaid-svg-Ag3p9gvky61FueL4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ag3p9gvky61FueL4 .icon-shape .label rect,#mermaid-svg-Ag3p9gvky61FueL4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ag3p9gvky61FueL4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ag3p9gvky61FueL4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ag3p9gvky61FueL4 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

相机帧流采集环绕 8 秒以上

特征提取帧间对应关系

点云初始化高斯分布初值

迭代优化位置与 alpha 收敛

裁剪导出受 maxPointCount 约束

PLY 落沙箱按商品 ID 命名

loadGSNode文件挂到 Scene

Component3D 渲染fillrate 受同屏点数约束

链路里最影响体验的是裁剪导出和渲染挂载,后面细说。

采集侧的关键 API 是 spatialRecon.ReconSession,它消费相机帧流,输出重建结果。渲染侧沿用设计向那篇已经讲过的 spatialRender.GSPlugin.loadGSNode,区别只在于 uri 指向的是本次重建刚落盘的 PLY/MP4,而不是预置资产。

需要主动指出的硬事实:Spatial Recon Kit 当前仅保证麒麟 9020 / 9030S / 9030 / 9030 Pro 及后续旗舰芯片的体验。我们手上 Pura 80 Pro(9020)跑得动,MatePad Pro 13.2 英寸(9030 Pro)也跑得动,但一台中端机(非上述芯片)查询 isSupported() 返回 true,实际重建到一半 OOM 退出。查询支持 ≠ 体验保证,这条在中端机覆盖场景里必须写进降级路径。

另一个硬事实:同一时刻只允许一个重建 session。第二个 session 创建会直接报 SESSION_BUSY。我们一开始在详情页和"再拍一件"按钮上各起一个 session,第二个总是失败,后来改成串行复用单例才稳定。

约束面:摆件场景的几条硬约束

约束实测表现对工程的影响
设备芯片 仅 9020/9030S/9030/9030 Pro 旗舰保证 商品详情页要做机型判断 + Mesh 降级
Session 单例 第二个 session 报SESSION_BUSY 详情页和"再拍"按钮共享一个 session
采集时长 官方建议 ≥ 8 秒环绕视频 引导页要明确告诉用户拍多久
输出体量 15cm 手办 PLY 约 18-26 MB 不能塞 rawfile,必须落沙箱
同屏高斯点 fillrate 敏感,> 80 万点开始掉帧 默认相机距离要卡住,不能让用户贴脸看

最后一条是渲染侧的隐性约束。3DGS 渲染开销不取决于总点数,取决于同屏高斯点数。手办总点数 60 万不算大,但相机贴到 5cm 距离时同屏能到 50 万,帧率从 60 掉到 38。这个我们一开始没料到,因为传统 Mesh 思维里"模型总面数"才是瓶颈。

场景落地:一个 15cm 手办的完整重建

被测物是一只 15cm 高的 PVC 机甲手办,表面有金属漆和高光细节,放在 60×40cm 桌面上,背景是浅灰墙面。采集设备 Pura 80 Pro,主摄 4 镜,环绕拍摄 10 秒,约 300 帧。

完整 ArkTS 流程如下,分三段:采集重建、落盘加载、渲染挂载。

1. 采集与重建

// entry/src/main/ets/recon/ProductRecon.ets
import { spatialRecon } from '@kit.SpatialReconKit';
import { camera } from '@kit.CameraKit';

export class ProductRecon {
private session: spatialRecon.ReconSession | null = null;

// 机型判断:非保证芯片直接走 Mesh 降级,不浪费用户时间
static shouldUse3DGS(): boolean {
// 实际项目里读 sysParam + 维护一份白名单
// 这里只示意,9020/9030S/9030/9030 Pro 才返回 true
return spatialRecon.DeviceCapability.isReconGuaranteed();
}

async startRecon(cameraInput: camera.CameraInput): Promise<string> {
// 单例保护:上一次 session 没释放就先销毁,避免 SESSION_BUSY
if (this.session !== null) {
this.session.release();
this.session = null;
}

const config: spatialRecon.ReconConfig = {
// targetObject 模式会尝试把主体外的背景剔除,对手办这种小物体很关键
mode: spatialRecon.ReconMode.TARGET_OBJECT,
// 输出 PLY:保真最高,体积最大;MP4 体积小但有损
outputFormat: spatialRecon.OutputFormat.PLY,
// 期望输出点数上限,超过会触发内部裁剪
maxPointCount: 800_000,
};

this.session = await spatialRecon.ReconSession.create(config);
// 喂帧:实际项目里从 CameraInput 流式拿 frame,这里简化为回调
this.session.bindCameraInput(cameraInput);

// 重建完成回调,返回落盘路径
return new Promise<string>((resolve, reject) => {
this.session!.on('complete', (result: spatialRecon.ReconResult) => {
// result.uri 形如 file:///data/storage/el2/base/files/recon/xxxx.ply
resolve(result.uri);
});
this.session!.on('error', (err: spatialRecon.ReconError) => {
reject(err);
});
// 主动开始,引导页倒计时 8 秒后调 stop
this.session!.start();
});
}

stopRecon(): void {
// 用户提前结束或引导页倒计时到,主动 stop 触发 complete 回调
this.session?.stop();
}
}

TARGET_OBJECT 模式是这次能跑出 11 秒级别的关键。它内部会做主体分割,背景点不参与优化,省掉了大量高斯点的迭代。我们一开始用默认的 FULL_SCENE 模式,手办背后的墙面也被重建,耗时直接翻倍到 23 秒,输出体量也涨到 41 MB。

2. 落盘与加载

重建结果已经在 ReconResult.uri 里落了沙箱,不需要再搬。但 PLY 18-26 MB 这个体量塞 rawfile 是不行的——APK 体积会被撑爆,且每个商品都打包一份不现实。正确做法是重建结果按商品 ID 命名落沙箱,详情页按 ID 复用。

// entry/src/main/ets/recon/ReconCache.ets
import { fileIo } from '@kit.CoreFileKit';

export class ReconCache {
// 沙箱根:实际项目按商品 ID 分目录,加 LRU 清理
private static readonly ROOT = '/data/storage/el2/base/files/recon';

static pathFor(productId: string): string {
return `${ReconCache.ROOT}/${productId}.ply`;
}

static async exists(productId: string): Promise<boolean> {
try {
await fileIo.accessSync(ReconCache.pathFor(productId));
return true;
} catch {
return false;
}
}

// LRU 清理:沙箱总占用超 200MB 时按最久未用删
static async pruneIfOver(limitBytes: number = 200 * 1024 * 1024): Promise<void> {
// 省略实现:列目录 → 按 atime 排序 → 删到总占用 < limitBytes
}
}

3. 渲染挂载

// entry/src/main/ets/pages/ProductDetail.ets
import { Scene, RenderContext, Node, Component3D } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';
import { ProductRecon } from '../recon/ProductRecon';
import { ReconCache } from '../recon/ReconCache';

@Entry
@Component
struct ProductDetail {
private scene: Scene | null = null;
@State ready: boolean = false;
@State reconMs: number = 0;

async aboutToAppear(): Promise<void> {
const productId = 'figure-001';

// 机型判断:非保证机型直接走 Mesh 详情页,不进 3DGS 分支
if (!ProductRecon.shouldUse3DGS()) {
this.goMeshFallback(productId);
return;
}

// 已有缓存就直接渲染,没有就引导用户拍
let plyUri: string;
if (await ReconCache.exists(productId)) {
plyUri = `file://${ReconCache.pathFor(productId)}`;
} else {
plyUri = await this.guideUserAndRecon(productId);
}

await this.loadAndRender(plyUri);
}

async loadAndRender(uri: string): Promise<void> {
const ctx = Scene.getDefaultRenderContext();
if (ctx === null) return;
// 必须先注册插件,否则 loadGSNode 是未定义行为
ctx.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);

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

const gsNode = await spatialRender.GSPlugin.loadGSNode(
scene, { uri, offset: 0 }, root
);
// 居中并缩放到统一尺寸,避免不同手办大小不一
gsNode.scale = { x: 0.5, y: 0.5, z: 0.5 };
gsNode.position = { x: 0, y: –0.15, z: 0 };
// 限制相机最近距离,防止贴脸看导致同屏高斯点爆炸掉帧
this.clampCameraMinDistance(0.3);
this.ready = true;
}

build() {
Column() {
if (this.ready && this.scene) {
Component3D({ scene: this.scene })
.width('100%').height('100%')
} else {
Text(`重建中… 已用 ${this.reconMs} ms`).fontSize(16)
}
}.width('100%').height('100%')
}
}

clampCameraMinDistance(0.3) 这一行是我们加的私货,官方示例没有。原因是用户双指捏合放大到最近 5cm 时同屏高斯点飙到 50 万,帧率掉到 38 fps。卡住最近距离 30cm 后,同屏稳定在 18 万点以内,60 fps 不掉。这个值要按物体尺寸调,手办 15cm 用 30cm,更大的物体要等比放大。

真机数据:Pura 80 Pro 上的实测

被测物:15cm PVC 机甲手办,浅灰墙面背景,主摄环绕 10 秒约 300 帧。设备 Pura 80 Pro(麒麟 9020),室温 25°C,电量 60%+,关闭其他后台。

指标数值备注
重建耗时 11-14 秒 背景干净 11s,背景杂乱 14s
输出 PLY 体量 18-26 MB 点数 60-78 万
输出 MP4 体量 4.2-5.8 MB 有损,高光细节有涂抹感
加载到首帧 0.4-0.7 秒 PLY 落沙箱后 loadGSNode
渲染帧率(中景) 58-60 fps 同屏约 12 万点
渲染帧率(近景 5cm) 36-42 fps 同屏约 50 万点
渲染内存增量 78-95 MB 含 GSNode + 上采样缓冲

11-14 秒这个区间波动 3 秒,主要变量是背景杂乱程度。同一只手办,桌面清空只放手办时 11.2 秒稳定;桌上多放两本书、一个马克杯时跑到 13.8 秒。TARGET_OBJECT 模式会尝试剔除背景,但分割本身有开销,背景越乱分割越慢。

我们一开始以为耗时波动是芯片温控降频,跑了三次都稳定在 11 秒后突然 14 秒,反复对比才发现是同事顺手把马克杯放回桌上了。这个坑排查了小半天。

输出体量上,PLY 18-26 MB 对单商品详情页可以接受,但一个电商 App 不可能只展示一件商品。我们试过 MP4 输出,4-5 MB 体积友好很多,但高光漆面有可见涂抹感,金属反光细节糊掉。最终选 PLY 落沙箱 + LRU 200MB 上限,按商品 ID 复用,用户看过的商品二次进入直接命中缓存。

踩坑与取舍

坑 1:背景干扰导致耗时波动 3 秒

已经提到,TARGET_OBJECT 模式依赖主体分割,背景越乱分割越慢。引导页后来加了一条:“请将商品放在干净桌面上,移开周围杂物”。这条文案上线后耗时波动从 3 秒压到 1 秒以内。

我们内部争论过要不要在采集前做一道用户手动框选主体的交互,框外直接不喂给 session。最后没做,原因是手办这种小物体用户框选的精度不如算法分割,反而引入新问题。但如果是大件商品(如家具),手动框选可能值得。

坑 2:点云文件塞不进包

第一版我们把重建结果当资产打进 APK 的 rawfile,发版包从 80 MB 涨到 230 MB,被商店拒了。改成"用户首次查看时引导拍摄 + 沙箱缓存"后,APK 回到 80 MB。代价是首次查看有 11 秒等待,二次查看 0.5 秒命中缓存。

这个取舍对电商场景成立:用户第一次点开详情页愿意等 11 秒看一个能转的 3D 模型,第二次就不愿意再等了。但如果是高频切换商品的场景(如比价页横向滑动),11 秒等待不可接受,得走预重建 + CDN 下发的另一条路。

坑 3:贴脸看掉帧

相机最近距离不卡,用户双指捏合放大到 5cm 时帧率掉到 38 fps。排查后发现是同屏高斯点飙到 50 万,fillrate 打满。卡住最近距离 30cm 后稳定 60 fps。这个限制要在 UI 上明确告诉用户"已最近距离",否则用户会以为卡了拼命再捏。

坑 4:中端机查询支持但跑不动

一台中端机(非保证芯片)isSupported() 返回 true,重建到 60% 进度时 OOM 退出,session 报 OUT_OF_MEMORY。我们后来加了 isReconGuaranteed() 这道更严格的判断,只对保证机型开放 3DGS 入口,其他机型走 Mesh 详情页。查询支持 ≠ 体验保证,这条要在文档里反复强调。

工程约束清单

  • 机型判断用 isReconGuaranteed() 而非 isSupported(),非保证机型直接 Mesh 降级
  • Session 全局单例,新 session 创建前先 release 旧的
  • 重建结果按商品 ID 落沙箱,LRU 200MB 上限,不打包进 APK
  • 输出格式选 PLY(保真)或 MP4(体积),按场景对高光细节的敏感度定
  • 渲染侧卡相机最近距离,按物体尺寸等比缩放阈值
  • 引导页明确告知"放在干净桌面、移开杂物、环绕 8 秒以上"
  • loadPlugin 必须在 loadGSNode 之前,顺序反了不报错但行为未定义

下一步可验证的动作哦

  • 在 MatePad Pro 13.2 英寸(9030 Pro)上跑同一只手办,对比重建耗时和渲染帧率,确认平板大屏是否因同屏点数更多而需要更激进的 LOD
  • 把 MP4 输出和 PLY 输出做 A/B,找 20 个真实用户看高光漆面细节差异,决定是否对低优先级商品切 MP4
  • 测一组 30cm 中型摆件(如花瓶),看 TARGET_OBJECT 分割是否还稳定,耗时是否线性增长
  • 接 LRU 清理后跑 50 个商品连续查看,确认沙箱占用稳定在 200MB 以下不溢出
  • 在我们测过的 3 台机器(Pura 80 Pro、MatePad Pro 13.2、一台中端机)范围内,11-14 秒重建 + 60 fps 中景渲染这条链路是成立的。如果后续 API 26 不大改 ReconSession 接口,这套方案可以直接进商品详情页灰度。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【共创稿事节】HarmonyOS 7商品展示场景:3DGS 重建一个小体积摆件的完整路径与真机数据
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!