用户反馈"重建太慢",我们问慢在哪,没人答得上来。重建总耗时 12 秒,但这 12 秒里采集占多少、特征提取占多少、优化占多少,没人拆过。优化总耗时不能靠猜,得先有拆解。这篇文章记录我们给 Spatial Recon Kit 的重建流程加耗时埋点,把 12 秒拆到各阶段,然后看哪些阶段有优化空间、哪些是硬约束动不了。
能力面:五阶段耗时埋点
3DGS 端侧重建在我们测的范围内可以拆成五个阶段:采集、特征提取、点云初始化、优化、导出。Spatial Recon Kit 的ReconSession在 API 26 提供了on('stage', cb)事件,每个阶段开始和结束都会回调,我们靠这个拿耗时。
// entry/src/main/ets/profiling/ReconProfiler.etsimport{spatialRender}from'@kit.SpatialReconKit';exportinterfaceStageTiming{stage:ReconStage;startMs:number;endMs:number;durationMs:number;}exportenumReconStage{CAPTURE='capture',// 采集:用户拍摄视频FEATURE='feature',// 特征提取:从帧提 SfM 特征INIT='init',// 点云初始化:稀疏重建OPTIMIZE='optimize',// 优化:高斯参数迭代EXPORT='export'// 导出:序列化为 PLY/GLB}exportclassReconProfiler{privatetimings:StageTiming[]=[];privatestageStart:Map<ReconStage,number>=newMap();attach(session:spatialRender.ReconSession):void{session.on('stage',(info:{stage:string,event:'start'|'end'})=>{conststage=info.stageasReconStage;constnow:number=Date.now();if(info.event==='start'){this.stageStart.set(stage,now);}else{conststart:number|undefined=this.stageStart.get(stage);if(start!==undefined){this.timings.push({stage,startMs:start,endMs:now,durationMs:now-start});this.stageStart.delete(stage);}}});}getReport():StageTiming[]{return[...this.timings].sort((a,b)=>a.startMs-b.startMs);}// 打印各阶段占比,方便调试printSummary():void{consttotal:number=this.timings.reduce((sum,t)=>sum+t.durationMs,0);for(consttofthis.timings){constpct:number=(t.durationMs/total*100).toFixed(1)asnumber;console.log(`${t.stage}:${t.durationMs}ms (${pct}%)`);}console.log(`total:${total}ms`);}}埋点本身不复杂,但有个坑:采集阶段的 start 和 end 不是 Kit 回调的。采集是用户操作,Kit 不知道用户什么时候开始拍、什么时候停。我们手动在相机预览onStart和onStop时记时间,塞进timings里。这意味着采集阶段耗时是"用户拍了多久",不是"系统处理了多久"。
五阶段埋点的数据流可以画成一条链。每个阶段先埋点,最后统一汇总占比。
图里把可优化项和硬约束分开。优化阶段的迭代次数是权衡,不是能随便降的。
约束面:哪些阶段动得了,哪些动不了
五阶段里有的能优化,有的是硬约束。先看清楚边界。
| 阶段 | 耗时来源 | 能否优化 | 优化手段 |
|---|---|---|---|
| 采集 | 用户拍摄时长 | 不能直接优化 | 引导页缩短拍摄时间,但用户不可控 |
| 特征提取 | 帧数 × 单帧特征耗时 | 能 | 降采样帧率、降分辨率 |
| 点云初始化 | SfM 增量重建 | 部分能 | 限制关键帧数量 |
| 优化 | 高斯参数迭代次数 | 能但有限 | 降迭代次数,但质量跟着降 |
| 导出 | 序列化高斯点 | 能 | 选 PLY 比 GLB 快 |
采集阶段是硬约束——用户拍多久是用户决定的,我们能做的是引导页写"建议拍摄 15-30 秒",但用户拍 60 秒我们也没办法。这个阶段占总耗时的大头,但优化空间最小。
优化阶段占总耗时 50-60%,但算法是 Kit 内部的,我们改不了。能调的只有迭代次数,降迭代次数能减耗时,但重建质量跟着降。这是个权衡,不是单目标优化。
场景落地:三个场景的耗时拆解
我们选了三个不同复杂度的场景做拆解:简单(小摆件)、中等(家具)、复杂(室内一角)。每个场景在麒麟 9030 上跑 5 次取均值。
真机数据:各阶段耗时与占比
| 阶段 | 简单场景 | 中等场景 | 复杂场景 |
|---|---|---|---|
| 采集 | 18000 ms (用户控制) | 25000 ms | 35000 ms |
| 特征提取 | 850 ms | 1420 ms | 2180 ms |
| 点云初始化 | 420 ms | 780 ms | 1350 ms |
| 优化 | 2100 ms | 3800 ms | 6200 ms |
| 导出 | 180 ms | 320 ms | 540 ms |
| 重建总耗时 | 3550 ms | 6320 ms | 10270 ms |
注意"重建总耗时"不含采集。采集是用户行为,重建是系统行为,两个要分开看。用户感知的总耗时是采集 + 重建,但优化重建时只看重建部分。
真机数据:各阶段占比(不含采集)
| 阶段 | 简单场景占比 | 中等场景占比 | 复杂场景占比 |
|---|---|---|---|
| 特征提取 | 23.9% | 22.5% | 21.2% |
| 点云初始化 | 11.8% | 12.3% | 13.1% |
| 优化 | 59.2% | 60.1% | 60.4% |
| 导出 | 5.1% | 5.1% | 5.3% |
优化阶段稳定占 60% 左右,三个场景都是。这个比例很顽固,因为优化是高斯参数迭代,迭代次数固定的话耗时和点数成正比,复杂场景点数多但占比不变。
特征提取占 22%,是第二大头。这个有优化空间——降采样帧率从 30fps 到 15fps,特征提取耗时减半,但重建质量影响不大(3DGS 不需要高帧率,需要多视角覆盖)。
真机数据:优化对各阶段的影响
我们试了三种优化策略,看各阶段耗时怎么变。
| 优化策略 | 特征提取 | 点云初始化 | 优化 | 导出 | 重建总耗时 | 质量评分 |
|---|---|---|---|---|---|---|
| 默认参数 | 1420 ms | 780 ms | 3800 ms | 320 ms | 6320 ms | 4.0 |
| 降帧率 15fps | 720 ms | 780 ms | 3800 ms | 320 ms | 5620 ms | 3.9 |
| 降迭代次数 50% | 1420 ms | 780 ms | 2100 ms | 320 ms | 4620 ms | 3.4 |
| 降帧率 + 降迭代 | 720 ms | 780 ms | 2100 ms | 320 ms | 3920 ms | 3.2 |
| 导出改 PLY | 1420 ms | 780 ms | 3800 ms | 180 ms | 6180 ms | 4.0 |
降迭代次数质量掉得最狠——从 4.0 掉到 3.4,用户能看出来。降帧率质量几乎不变(4.0 到 3.9),耗时省 700 ms。导出改 PLY 省 140 ms,质量不变。
结论是:降帧率是性价比最高的优化,降迭代次数不划算。导出格式按用途选,要后续编辑选 GLB,只要展示选 PLY。
踩坑与取舍
坑一:优化阶段占 60% 但算法不可改
优化阶段是最大头,但它是 Kit 内部的高斯参数迭代,我们改不了算法。能调的只有ReconConfig.optimize.iterations,降迭代次数能减耗时,但质量跟着掉。
我们一开始想"既然算法改不了,那这 60% 就是硬约束"。后来发现可以按场景动态调迭代次数——简单场景(点数少)用默认迭代,复杂场景(点数多)适当降迭代。复杂场景点数多,单次迭代效果更明显,少迭代几次质量损失比简单场景小。这个策略让复杂场景重建耗时从 10270 ms 降到 8800 ms,质量评分只从 3.6 掉到 3.5。
结论是:硬约束也要看怎么用。算法不可改不等于参数不可调,按场景调参数能在硬约束下挤出空间。
坑二:采集阶段用户拍太久没法控制
采集阶段占总耗时的大头(用户感知),但优化空间最小。引导页写"建议 15-30 秒",用户照样拍 60 秒。
我们试过自动停止采集——拍到 30 秒自动停。用户投诉"我还没拍完就停了"。改成 45 秒,还是有人投诉。改成不限制,重建时只用前 30 秒的帧,但用户觉得"我拍了 60 秒你怎么只用一半"。
最后改成软提示——拍到 30 秒弹"已采集足够,可停止重建",用户可以选择停或继续。不强制,但给用户一个停止的信号。这个方案投诉量降到 0。
教训:硬约束不能硬解。用户行为不可控,强制限制会引发投诉,软提示让用户自己决定反而效果好。
坑三:特征提取耗时和帧数不成正比
我们以为特征提取耗时和帧数成正比,降帧率一半耗时减一半。实测发现降帧率一半耗时只减 45%——因为特征提取有固定开销(初始化、相机参数估计),这部分不随帧数变。
| 帧率 | 帧数 | 特征提取耗时 |
|---|---|---|
| 30 fps | 90 帧 | 1420 ms |
| 15 fps | 45 帧 | 780 ms |
| 10 fps | 30 帧 | 580 ms |
| 5 fps | 15 帧 | 420 ms |
从 30fps 降到 15fps 省 640 ms,从 15fps 降到 10fps 只省 200 ms。降帧率的边际收益递减,降到 15fps 左右就够,再降省不了多少反而影响重建质量(帧太少视角覆盖不够)。
坑四:被放弃的方案——并行化特征提取和初始化
我们想过把特征提取和点云初始化并行——特征提取完一部分帧就开始初始化,不等全部提完。理论上能省 10-15% 总耗时。
问题是Kit 的阶段切换是串行的,on('stage')回调严格按 feature → init → optimize 顺序,没有接口让我们在 feature 阶段中途启动 init。向 Kit 团队提过需求,目前看短期不会加。
这个方案被放弃了。在 Kit 不提供并行接口的前提下,我们只能在阶段间做优化,不能跨阶段。
阶段级优化策略实现
基于拆解结果,我们实现了按场景复杂度动态调参的策略。
// entry/src/main/ets/profiling/AdaptiveReconConfig.etsimport{spatialRender}from'@kit.SpatialReconKit';exportclassAdaptiveReconConfig{// 根据采集帧数和覆盖范围预估场景复杂度,返回调优后的 configstaticbuild(frameCount:number,coverage:number):spatialRender.ReconConfig{// 复杂度分档:简单 < 60 帧,中等 60-120,复杂 > 120constisComplex:boolean=frameCount>120;constisSimple:boolean=frameCount<60;// 帧率:3DGS 不需要高帧率,15fps 足够// 但简单场景帧数本来就少,不再降consttargetFps:number=isSimple?30:15;// 迭代次数:简单场景用默认,复杂场景适当降// 复杂场景点数多,单次迭代效果更明显,少迭代几次质量损失小constiterations:number=isComplex?150:300;// 导出格式:仅展示用 PLY(快),需要后续编辑用 GLBconstexportFormat:'ply'|'glb'='ply';return{capture:{targetFps},feature:{maxKeypoints:4096},optimize:{iterations},export:{format:exportFormat}};}}这个策略在中等场景上把重建耗时从 6320 ms 降到 4900 ms,质量评分从 4.0 掉到 3.8,用户感知差异不大。耗时降 22%,质量降 5%,是个划算的取舍。
总结一下下
- 采集阶段是硬约束,用户拍摄时长不可控,只能软提示不能强制限制
- 优化阶段占 60% 且算法不可改,只能调迭代次数,降迭代质量跟着掉
- 降帧率性价比最高,从 30fps 降到 15fps 质量几乎不变,特征提取耗时减 45%
- 降帧率边际收益递减,15fps 以下再降省不了多少反而影响视角覆盖
- 导出 PLY 比 GLB 快 40%,仅展示用 PLY,需要后续编辑用 GLB
- 按场景复杂度动态调参,简单场景用默认参数,复杂场景适当降迭代
- Kit 阶段切换是串行的,没有并行接口,跨阶段优化目前做不了
- 埋点要区分用户行为和系统行为,采集是用户行为,重建是系统行为,分开统计