ITPub博客

首页 > IT基础架构 > 服务器/存储 > 技术解读:【共创稿事节】HarmonyOS 7高斯点裁剪与 LOD 在端侧的体量控制取舍

技术解读:【共创稿事节】HarmonyOS 7高斯点裁剪与 LOD 在端侧的体量控制取舍

服务器/存储 作者:二进制猎手 时间:2026-10-19 09:59:50 0 删除 编辑

3DGS 重建结果动辄几十万高斯点,端侧扛不住。我们用 Pura 80 Pro 重建一个 1.5 米高的陶瓷摆件,跑出 82 万点,光加载就吃 380 MB 内存,渲染稳态 38 fps,离 60 fps 还差一截。重建质量是够了,但这个体量塞不进商品展示的卡片流。必须裁。裁狠了细节崩,裁少了还是卡,中间还夹着 LOD 切换的跳变问题。这篇把我们在裁剪策略上的几轮取舍和真机数据摊出来。

能力面:三种裁剪策略各自裁的是什么

3DGS 的高斯点不是顶点,是一组带属性的概率分布。每个点有位置、协方差(旋转+缩放)、颜色 SH 系数、不透明度 alpha。裁剪就是按某个指标排序,删掉贡献小的点。指标选错,删的就是细节。

透明度阈值裁剪

最简单。alpha 低于某阈值直接删。重建优化过程本身就会产生大量 alpha 趋近 0 的点,这些点是"被优化掉"的残留,删了基本无感。

阈值取多少有讲究。我们一开始取 0.01,删了 18 万点,看起来很爽。后来在陶瓷摆件的釉面边缘发现锯齿——边缘那些半透明点正是用来做反走样的,alpha 在 0.02~0.05 之间,被一并删了。 透明度阈值取 0.005 是我们这台机器上的安全线 ,再高就开始伤边缘。这个数只在我们测过的 3 个摆件上成立,换一类物体可能要重测。

体积贡献裁剪

按高斯椭球体积 × alpha 排序,删底部。这个指标衡量的是"这个点对画面的实际填充贡献"。一个又小又透明的点,删了画面几乎不变;一个又大又透明的点(比如雾、玻璃后的散景),反而要保留。

体积贡献裁剪比透明度裁剪安全,但慢。每个点要算协方差行列式,82 万点排序一遍在我们机器上约 240 ms。能离线做就别在线做。

距离 LOD

前两种是静态裁剪,一次裁好固定用。LOD 是动态的:按相机距离准备多个精度版本,近看高精度、远看低精度,切换时换文件。

LOD 的好处是同屏点数可控。坏处是切换瞬间会有视觉跳变——高精度版本的边缘半透明点和低精度版本的硬边在切换那一帧肉眼可见。这个跳变是后面踩坑的主角。

被放弃的方案:按 SH 能量裁剪

我们试过第四种思路:按球谐系数能量裁剪。想法是颜色变化剧烈的点(高 SH 能量)保留,颜色平坦的点删掉。听起来合理,颜色平坦的区域少几个点无所谓。

实际跑下来翻车了。陶瓷摆件的釉面是大片颜色平坦的高光区,按 SH 能量裁剪把这些点删了一半,高光区域出现明显的颗粒感——平坦不等于不重要,大面积的平坦点叠加出来才是连续的高光面。这个方案在毛绒玩具上反而效果不错,绒毛颜色变化大、SH 能量高,裁剪后保留的就是毛发结构。但商品展示要覆盖多类物体,一个只在某类上 work 的策略不敢上生产线。 SH 能量裁剪我们放弃了,留作未来按物体类型分策略时再考虑 。

三种策略横向对比

| 策略 | 裁剪指标 | 计算开销 | 安全性 | 适用物体 |
| --- | --- | --- | --- | --- |
| 透明度阈值 | alpha | 极低(一遍遍历) | 低,易伤边缘 | 重建残留点清理 |
| 体积贡献 | detCov × alpha | 中(排序 240 ms/82 万点) | 中,删底部较稳 | 通用,配合透明度用 |
| 距离 LOD | 相机距离 | 低(切换时双版本内存上抬) | 中,切换跳变需淡入 | 大场景、可走动浏览 |
| SH 能量(放弃) | SH 系数能量 | 中 | 低,伤平坦高光区 | 仅毛绒类,已放弃 |

我们生产环境用的是透明度 + 体积贡献双过滤,离线生成多个静态档位,再按机型下发。LOD 只在需要走动浏览的大场景上用,小摆件展示用静态档位够了。

生产环境这套裁剪决策可以概括成下面这张图。

约束面:裁剪的边界条件

| 约束 | 表现 | 我们的处理 |
| --- | --- | --- |
| 裁剪过度丢细节 | 边缘锯齿、半透明区域出现空洞 | 透明度阈值卡 0.005,体积贡献只删底部 5% |
| LOD 切换跳变 | 切换瞬间可见"闪一下" | 切换点放在相机距离 1.2 m 处,且做 200 ms 交叉淡入 |
| 设备内存上限差异 | 麒麟 9020 重建内存上限低于 9030 Pro | 9020 上强制裁到 30 万点以下,9030 Pro 放宽到 60 万 |
| 重建质量回退 | 裁剪后 SSIM 下降不可逆 | 保留原始重建结果,裁剪只生成衍生版本 |

设备那条要单独说。Spatial Recon Kit 仅保证麒麟 9020 / 9030S / 9030 / 9030 Pro 及后续旗舰芯片的体验,查询 API 返回支持不等于体验保证。我们在一台查询返回支持的麒麟 9000 机器上跑,重建本身能起来,但 80 万点加载直接 OOM。 裁剪策略必须按真实可用内存分档,不能只看芯片型号 。

还有一条容易漏的约束:裁剪不可逆。一旦生成裁剪后的 PLY 并删掉原始版本,SSIM 回退就找不回来了。我们第一版管线图省存储,重建完直接裁剪存裁剪版,原始版本不留。后来想换裁剪参数重测,发现原始点云已经被采集视频覆盖了——采集视频也删了。只能重新拍一遍,一个摆件重拍半天。 原始重建结果必须留底,至少留到裁剪参数定型那天 。存储成本不算大,PLY 压缩后 198 MB,留 200 个 SKU 的原始版本约 40 GB,对服务器不是问题。

场景落地:82 万点裁到四档

同一个陶瓷摆件重建结果,原始 82 万点,我们裁出四档对比。裁剪用体积贡献 + 透明度 0.005 双过滤,离线一次性生成。

| 裁剪档位 | 高斯点数 | 文件体量 (PLY) | 加载耗时 | 稳态帧率 | 内存峰值 | SSIM vs 原始 |
| --- | --- | --- | --- | --- | --- | --- |
| 原始 | 82.4 万 | 198 MB | 1.9 s | 38 fps | 380 MB | 1.000 |
| 裁 60 万 | 60.1 万 | 145 MB | 1.4 s | 47 fps | 285 MB | 0.987 |
| 裁 40 万 | 40.2 万 | 97 MB | 1.0 s | 56 fps | 198 MB | 0.962 |
| 裁 20 万 | 19.8 万 | 48 MB | 0.6 s | 60 fps | 112 MB | 0.901 |
| 裁 10 万 | 9.9 万 | 24 MB | 0.3 s | 60 fps | 68 MB | 0.823 |

测试机型 Pura 80 Pro(麒麟 9030),渲染窗口 1080×1440,相机距离 0.8 m 环视。帧率上限锁 60。

  • 裁 20 万档稳态 60 fps,但相机推近到 0.3 m 时掉到 51 fps——同屏点数随距离变化,不是恒定。

  • 裁 10 万档 SSIM 0.823,釉面高光区域肉眼可见发虚,已经不适合商品展示。

  • 加载耗时不是线性递减,裁 10 万档 0.3 s 里有 0.12 s 是固定开销(插件初始化、场景搭建)。

40 万档是我们这个摆件在 Pura 80 Pro 上的甜点 :56 fps 接近 60,SSIM 0.962 几乎无感差异,文件 97 MB 能塞进应用包外置下载。再往下裁收益递减,再往上裁帧率掉得明显。

跨设备对比:同一档位不同机型表现差很大

把 40 万档放到三台机器上跑,差异比预想的大。

| 机型 | 芯片 | 加载耗时 | 稳态帧率 | 内存峰值 | 推近 0.3 m 帧率 |
| --- | --- | --- | --- | --- | --- |
| Pura 80 Pro | 麒麟 9030 | 1.0 s | 56 fps | 198 MB | 51 fps |
| Mate 70 Pro | 麒麟 9020 | 1.3 s | 49 fps | 210 MB | 42 fps |
| Mate 70 标准版 | 麒麟 9020 | 1.4 s | 47 fps | 215 MB | 39 fps |

Mate 70 Pro 跑 40 万档只有 49 fps,离 60 还差一截。这台机器上甜点要往下挪到 30 万档。 裁剪档位不能跨设备一刀切,必须按机型分档下发 。我们的做法是应用启动时读设备型号,从 CDN 拉对应档位的 PLY。代价是存储侧要存多份,但端侧体验是值得的。

裁剪参数怎么定:从拍脑袋到有依据

最开始裁剪参数是拍脑袋定的:透明度 0.01、体积贡献删底部 10%。跑出来发现边缘锯齿,把透明度挪到 0.005、体积贡献删底部 5%,好了。但换一个物体——一只毛绒玩具——同样的参数裁完,绒毛区域出现空洞,因为毛绒玩具的"细节点"alpha 普遍在 0.01~0.03 之间,0.005 阈值刚好不删,但体积贡献删底部 5% 把大量小体积绒毛点删了。

我们后来定参数的流程是:每个新物体类型先跑一次"裁剪扫描"——透明度阈值从 0.001 扫到 0.05,体积贡献删底部比从 1% 扫到 20%,每个组合跑一次 SSIM 评估,画一张热力图。热力图上 SSIM 急剧下降的拐点就是该物体类型的裁剪边界。陶瓷摆件的拐点在透明度 0.005 / 体积删 8%,毛绒玩具在透明度 0.003 / 体积删 3%。不同物体类型裁剪参数差一倍很正常,不要指望一套参数通吃。

这套扫描跑一遍要 40 分钟(82 万点 × 30 个参数组合 × SSIM 评估),但只跑一次就能定参数,比每次上线都靠 QA 提单发现锯齿划算得多。我们把扫描脚本放在重建管线的可选步骤里,新接入一类物体时跑一次。

踩坑:两个被用户投诉的方案

坑一:按透明度裁剪把边缘全裁没了

第一版裁剪只走透明度阈值,取 0.05。陶瓷摆件从 82 万裁到 38 万,帧率上来了,团队内部看了一眼觉得"挺好"。第二天 QA 提单:摆件把手处的镂空边缘出现明显锯齿,釉面和背景的过渡带出现一圈白边。

原因前面提过,边缘半透明点被一刀切了。修复方案是改成体积贡献为主、透明度为辅的双指标过滤,透明度阈值放宽到 0.005。代价是裁剪慢了 3 倍,但裁剪离线做,慢点能接受。

教训: 任何只看单一指标的裁剪都会在某类物体上翻车 。陶瓷的反走样点、毛绒玩具的绒毛点、玻璃器皿的折射点,这三类的"细节点"指标分布完全不同,单一阈值顾不过来。

坑二:LOD 切换跳变被用户投诉

第二版我们上 LOD,准备了 40 万和 10 万两档。相机距离 > 1.5 m 用 10 万档,< 1.5 m 用 40 万档。逻辑上没毛病,内测时产品同学反馈"走近摆件时会闪一下"。

复现下来是切换那一帧:10 万档的硬边和 40 万档的半透明边在 1.5 m 处瞬间替换,肉眼可见。我们把切换点从 1.5 m 挪到 1.2 m(用户推近时的注意力通常在物体上,1.2 m 处切换不易察觉),再加 200 ms 的两档交叉淡入。淡入期间同时渲染两个 GSNode,内存峰值短暂上抬约 80 MB,淡入完释放低精度档。

改完之后内测没人再提"闪"。但交叉淡入的代价是切换瞬间内存峰值冲高,在麒麟 9020 上要把低精度档压到 8 万以下才不 OOM。 LOD 不是免费的,切换瞬间的双版本内存要算进预算 。

坑三:裁剪档位选错,外置下载包体积爆了

第三轮踩坑不在渲染,在分发。我们给每个商品生成 4 个裁剪档(60 万/40 万/20 万/10 万),按机型下发。一个 SKU 下来 PLY 总共 145+97+48+24 = 314 MB。商品库 200 个 SKU,存储侧要 60 GB。

运营同学找过来的时候我们才意识到裁剪档位粒度太细。200 个 SKU 不可能每个都 4 档,热门款 4 档、长尾款只留 1 个甜点档(40 万)就够了。改完之后存储降到 25 GB 左右。裁剪档位是存储成本,不是纯技术决策,要和运营一起拍。

ArkTS:双指标裁剪实现

裁剪逻辑跑在端侧,但不在渲染热路径上,重建完成后调一次即可。下面是体积贡献 + 透明度双过滤的实现骨架。

import
{
spatialRender
}
from
'@kit.SpatialReconKit'
;
interface
GsPointStats
{
index
:
number
;
alpha
:
number
;
detCov
:
number
;
shEnergy
:
number
;
}
export
function
pruneByDualMetric
(
stats
:
GsPointStats
[
]
,
alphaThreshold
:
number
,
volumeKeepRatio
:
number
,
)
:
Set
<
number
>
{
const
keepSet
:
Set
<
number
>
=
new
Set
(
)
;
const
candidates
:
GsPointStats
[
]
=
[
]
;
for
(
const
s
of
stats
)
{
if
(
s
.
alpha
<
alphaThreshold
)
{
continue
;
}
candidates
.
push
(
s
)
;
keepSet
.
add
(
s
.
index
)
;
}
candidates
.
sort
(
(
a
:
GsPointStats
,
b
:
GsPointStats
)
:
number
=>
{
const
scoreA
:
number
=
a
.
detCov
*
a
.
alpha
*
(
1
+
a
.
shEnergy
)
;
const
scoreB
:
number
=
b
.
detCov
*
b
.
alpha
*
(
1
+
b
.
shEnergy
)
;
return
scoreB
-
scoreA
;
}
)
;
const
keepCount
:
number
=
Math
.
floor
(
candidates
.
length
*
volumeKeepRatio
)
;
for
(
let
i
:
number
=
keepCount
;
i
<
candidates
.
length
;
i
++
)
{
keepSet
.
delete
(
candidates
[
i
]
.
index
)
;
}
return
keepSet
;
}
export
async
function
writePrunedPly
(
srcUri
:
string
,
dstUri
:
string
,
keepSet
:
Set
<
number
>
,
)
:
Promise
<
void
>
{
const
pruner
:
spatialRender
.
GSPruner
=
spatialRender
.
GSPlugin
.
createPruner
(
)
;
await
pruner
.
rewrite
(
srcUri
,
dstUri
,
keepSet
)
;
}

调用侧:重建完成后调一次 pruneByDualMetric ,生成 40 万和 20 万两个衍生版本,原始版本留底。LOD 的两档交叉淡入用两个 GSNode 叠加,可见性按相机距离联动。

LOD 切换的交叉淡入

import
{
spatialRender
}
from
'@kit.SpatialReconKit'
;
import
{
Scene
,
Node
}
from
'@kit.ArkGraphics3D'
;
export
class
LodCrossFade
{
private
highNode
:
spatialRender
.
GSNode
|
null
=
null
;
private
lowNode
:
spatialRender
.
GSNode
|
null
=
null
;
private
fading
:
boolean
=
false
;
constructor
(
high
:
spatialRender
.
GSNode
,
low
:
spatialRender
.
GSNode
)
{
this
.
highNode
=
high
;
this
.
lowNode
=
low
;
}
async
maybeSwitch
(
distance
:
number
,
switchAt
:
number
)
:
Promise
<
void
>
{
if
(
this
.
fading
||
this
.
highNode
===
null
||
this
.
lowNode
===
null
)
{
return
;
}
const
needHigh
:
boolean
=
distance
<
switchAt
;
if
(
needHigh
&&
!
this
.
highNode
.
visible
)
{
await
this
.
fadeToHigh
(
)
;
}
else
if
(
!
needHigh
&&
this
.
highNode
.
visible
)
{
await
this
.
fadeToLow
(
)
;
}
}
private
async
fadeToHigh
(
)
:
Promise
<
void
>
{
this
.
fading
=
true
;
this
.
highNode
!
.
visible
=
true
;
for
(
let
t
:
number
=
0
;
t
<=
200
;
t
+=
16
)
{
const
k
:
number
=
t
/
200
;
this
.
highNode
!
.
opacity
=
k
;
this
.
lowNode
!
.
opacity
=
1
-
k
;
await
sleep
(
16
)
;
}
this
.
lowNode
!
.
visible
=
false
;
this
.
lowNode
!
.
opacity
=
1
;
this
.
fading
=
false
;
}
private
async
fadeToLow
(
)
:
Promise
<
void
>
{
this
.
fading
=
true
;
this
.
lowNode
!
.
visible
=
true
;
for
(
let
t
:
number
=
0
;
t
<=
200
;
t
+=
16
)
{
const
k
:
number
=
t
/
200
;
this
.
lowNode
!
.
opacity
=
k
;
this
.
highNode
!
.
opacity
=
1
-
k
;
await
sleep
(
16
)
;
}
this
.
highNode
!
.
visible
=
false
;
this
.
highNode
!
.
opacity
=
1
;
this
.
fading
=
false
;
}
}
function
sleep
(
ms
:
number
)
:
Promise
<
void
>
{
return
new
Promise
(
(
resolve
:
(
)
=>
void
)
:
void
=>
{
setTimeout
(
resolve
,
ms
)
;
}
)
;
}

淡入期间两个 GSNode 同时可见,内存峰值短暂上抬。麒麟 9020 上我们把低精度档压到 8 万点以下,峰值上抬约 45 MB,能扛住。9030 Pro 上低精度档可以放到 20 万,峰值上抬约 110 MB,也安全。

裁剪管线在重建流程里的位置

裁剪不是独立步骤,要嵌进重建管线的尾部。我们这条管线的顺序是:采集视频 → 重建 → 质量评估 → 裁剪 → 分档存储 → 下发。

质量评估在裁剪前面,原因是裁剪参数要按重建质量调。同一个采集流程,光照好时重建出 80 万点、SSIM 0.95;光照差时重建出 110 万点、SSIM 0.82。后者的点更多但质量更差,里面有大量噪声点。如果都按"删底部 5%"裁,后者裁完还是 104 万点,照样卡。我们的做法是质量评估不达标时,裁剪比例自动加大——SSIM 低于 0.85 时删底部 20%,等于多删一点噪声点换帧率。这个策略在低质量重建上能救一下,但本质上是重建质量的问题,裁剪只是兜底。

分档存储前面提过,按机型分档下发。下发侧还有一个细节:PLY 用 OhosRawFile 还是 file://。包内的 rawfile 体积有限制,40 万档 97 MB 塞不进包,只能走外置下载到沙箱再 file:// 加载。10 万档 24 MB 可以塞包内做首屏兜底,用户没下载完高精度档时先看低精度档。 首屏兜底档塞包内、高精度档外置下载,这个组合在我们商品展示场景里效果最好 。

总结一下下

  • 裁剪必须双指标,单指标会在某类物体上翻车,陶瓷/毛绒/玻璃三类细节点分布不同

  • 透明度阈值取 0.005 起步,新物体类型必须重新验证,不要直接套用

  • 体积贡献裁剪离线做,82 万点排序约 240 ms,主线程会卡 UI

  • LOD 切换点放在相机距离 1.2 m,配合 200 ms 交叉淡入,切换瞬间内存峰值要算进预算

  • 麒麟 9020 上 LOD 低精度档压到 8 万点以下,9030 Pro 可放到 20 万

  • 原始重建结果留底,裁剪只生成衍生版本,SSIM 回退不可逆

  • 设备分档不能只看芯片型号,要按真实可用内存,查询支持 ≠ 体验保证

  • 新物体类型接入时跑一次裁剪参数扫描(透明度×体积贡献网格),40 分钟换参数定型

  • 裁剪档位是存储成本,热门款 4 档、长尾款 1 档,和运营一起拍

  • 首屏兜底档塞包内(10 万档 24 MB),高精度档外置下载到沙箱 file:// 加载

下一步哦

我们正在做的下一步是把裁剪档位选择自动化:重建完成后跑一次 SSIM 评估,按目标帧率反推最大允许点数,自动选最接近的裁剪档。目前档位是人工拍脑袋定的,换一个物体就要重测一遍,不够工程化。如果后续 Spatial Recon Kit API 不变的话,我们打算把这个自动选档做成一个独立工具,重建管线跑完直接出三个候选版本供运行时挑选。 第二个想做的事是按物体类型分裁剪策略。现在所有物体都走透明度+体积贡献双过滤,但毛绒玩具和陶瓷摆件的细节点分布差太多,一套参数两边都不最优。理想状态是重建时识别物体类型,按类型选裁剪策略和参数。物体类型识别可以复用端侧 AI 检测能力,但那又引入了 AI 模型加载的资源争用,要单独评估。


转载声明:本文为转载整理,原文版权归原作者及原站点所有。

原文来源:CSDN HarmonyOS

原创地址:https://blog.csdn.net/qq_46104835/article/details/167221469

原文作者:HwJack20;如有侵权,请联系管理员删除。

下一篇: 没有了~
请登录后发表评论 登录
全部评论

注册时间:2018-03-25

  • 博文量
    12
  • 访问量
    1044