做 3D 内容最劝退的一环,从来不是渲染,是"把真实世界搬进屏幕"。建模师在 DCC 里拉几天面片,导出几十兆模型,到手机上还要重新减面、重烘焙贴图。3DGS(3D Gaussian Splatting)绕开了这条路径:拍一段视频,用一组带属性的高斯点去逼近真实场景,光影和纹理几乎是采集来的。HarmonyOS 7(API 26)把这件事放到了端侧,Spatial Recon Kit 提供加载与渲染能力,设计侧要回答的问题随之变了------不再是"怎么把模型做小",而是"拿到真实空间之后,应用还能做什么"。
3DGS 和传统建模,描述的是两种东西
传统建模在描述表面。顶点、三角面、UV、贴图组成一层壳,光照由材质和灯光实时计算,模型是"空心的形状"。3DGS 描述的是体积:几十万到几百万个高斯点(Gaussian Point)填充空间,每个点带位置、颜色、透明度、旋转和缩放,渲染时投影到屏幕,按深度做 alpha 混合,叠加出完整的场景。
这个差别会传导到工作流上。传统流程的成本大头在建模和贴图;3DGS 的成本大头在采集和算力。前者考验美术水平,后者考验数据质量。
| 维度 | 传统多边形建模 | 3DGS 重建 |
|---|---|---|
| 数据来源 | 人工建模 + 贴图绘制 | 拍摄采集 + 算法重建 |
| 典型体量 | 单模型数 MB 到数十 MB | 单场景数十 MB 到数百 MB |
| 结构 | 有拓扑,可编辑、可动画 | 点云式,无拓扑,编辑受限 |
| 真实感 | 依赖美术能力 | 依赖采集质量,光影天然保留 |
| 视角表现 | 视差正常 | 视差正常,非全景"单点环视" |
| 文件格式 | glTF / GLB / FBX | MP4 / PLY / GLB |
| 渲染开销 | 受面数、材质影响 | 受同屏高斯点数量影响(fillrate 敏感) |
看清这张表,后面的设计取舍就有依据了:3DGS 换来了真实感,代价是体积和渲染压力都更大。
端侧重建改掉的三个前提
真实资产不用再"造"
家具应用要给用户看一张真实沙发,过去只能买模型、或者扫描后云端重建再下载。端侧重建把采集、重建、渲染放进同一台设备,用户拍到的就是拿到的。对设计来说,这意味着"内容供给"不再是瓶颈------产品可以先做互动,再补资产。
迭代周期从"天"压到"小时"
内容团队改一个展厅布局,2D 时代要重新出图、切图、发版。3DGS 场景可以整段替换,重建一次就能看效果。评审从"看图"变成"进场景走一遍",反馈更直接,返工更少。
数据不出设备
卧室、店铺内部、工厂车间这类空间,拍完上传到云端本身就涉及隐私与合规。端侧完成重建,原始采集数据可以只留在本地,重建结果也由用户决定是否分享。这不只是技术选项,是很多场景能不能做的前提。
把模型加载进场景
Spatial Recon Kit 的入口是 spatialRender,它依赖 @kit.ArkGraphics3D 的 Scene 与渲染上下文。顺序不能乱:先注册插件 ID,再加载场景,最后挂 GS 节点。
typescript
// entry/src/main/ets/entryability/EntryAbility.ets
import { Scene, RenderContext, Node } from '@kit.ArkGraphics3D';
import { spatialRender } from '@kit.SpatialReconKit';
@Entry
@Component
struct GsViewer {
private scene: Scene | null = null;
aboutToAppear(): void {
// 1. 拿到默认渲染上下文,后续插件注册都挂在它上面
const renderContext: RenderContext | null = Scene.getDefaultRenderContext();
if (renderContext === null) {
console.error('no render context, abort');
return;
}
// 2. 必须先注册 GSPlugin,否则调用 GSPlugin 的接口属于未定义行为
renderContext.loadPlugin(spatialRender.GSPlugin.PLUGIN_ID);
// 3. 加载场景,成功后把 3DGS 节点挂到根节点下
Scene.load().then(async (scene: Scene) => {
this.scene = scene;
if (!scene.root) {
return;
}
const root: Node = scene.root as Node;
// uri 支持 OhosRawFile:// 与 file:// 两种前缀;格式支持 mp4 / ply / glb
const uri = 'OhosRawFile://assets/gltf/room.glb';
const gsNode: spatialRender.GSNode =
await spatialRender.GSPlugin.loadGSNode(scene, { uri, offset: 0 }, root);
// GSNode 继承自 Node,可直接做位移、缩放、可见性控制
gsNode.position = { x: 0, y: -0.2, z: 0 };
gsNode.scale = { x: 1, y: 1, z: 1 };
gsNode.visible = true;
}).catch((err: string) => {
console.error('load 3dgs failed: ' + err);
});
}
build() {
Column() {
if (this.scene) {
// Component3D 负责把 Scene 渲染出来,尺寸交给布局
Component3D({ scene: this.scene })
.width('100%')
.height('100%')
}
}.width('100%').height('100%')
}
}
一个房间用 loadGSNode 就够了。但一栋楼、一个园区不行------几百 MB 的高斯点不可能一次性进内存。API 26 引入的分块 3DGS(Tiled 3D Gaussian Splatting)解决的就是这个:把场景按空间切成瓦片,渲染器在相机移动时按需请求当前视口附近的瓦片。
typescript
// 大场景按需加载:相机移到哪,渲染器就请求哪一片
const params: spatialRender.TiledGSImportSettings = {
uri: 'file:///data/storage/el2/base/files/campus/campus.scene.json' // 清单文件描述瓦片层级
};
const tiled: spatialRender.TiledGSNode =
await spatialRender.GSPlugin.loadTiledGSNode(scene, params, root);
// 指定驱动分块选择的相机:相机的位置决定了要加载哪些瓦片
tiled.setCamera(camera);
// 渲染器需要数据时回调,应用负责把瓦片拉到本地再通知就绪
tiled.setTileRequestCallback((tiles: spatialRender.GSTile[]) => {
for (const tile of tiles) {
// 远端拉取 -> 落盘到 tile.uri 指向的相对路径 -> 通知渲染器可读
void downloadTile(tile.uri).then(() => {
tiled.notifyTileReady(tile);
});
}
});
分块加载把"一次性几百 MB"变成"首屏几 MB + 走到哪加载到哪"。设计上要多考虑一件事:瓦片加载是有延迟的,远景区域会先以低精度出现,再逐步清晰。这不是 bug,是设计要接受的渐进呈现。
设计机会不止"看模型"
刚上手容易把 3DGS 当成"更真实的模型预览"。真正有意思的是它上面叠的那一层交互。
- 场景标注层:高斯点是死的、不可拾取的,手势和视线需要一个可命中的目标。做法是在场景里挂普通 Node 作为热点,用 Raycast 拾取这些 Node,而不是去点高斯点本身。
- 虚拟陈设:真实房间重建后叠加 glTF 家具,用位移和缩放调整位置,做"摆放前先看效果"。
- 空间导览:Tiled 场景配合相机路径,做展馆、厂区的走动式浏览。
- 隐私敏感建模:卧室、试衣间、店铺内部,端侧完成,原始数据不外传。
- 记录与回访:装修前拍一遍留底,之后随时进入对比。
约束在哪里,设计就得在哪里让步
| 约束 | 具体表现 | 设计对策 |
|---|---|---|
| 模型体量 | 单场景可达数百 MB | 大场景走 Tiled 分块,小场景控制采集范围 |
| 渲染性能 | 同屏高斯点过多会掉帧 | 限制相机拉近距离,默认视距保持中景 |
| 加载延迟 | 瓦片按需拉取,远景先模糊 | 接受渐进呈现,用进度提示代替"转圈等待" |
| 交互命中 | 高斯点不可直接拾取 | 叠加可拾取 Node 作为交互代理 |
| 设备支持 | 暂不支持模拟器,仅境内设备 | 降级方案要准备,开发期用真机验证 |
| 数据格式 | MP4 / PLY / GLB 三种 | 按用途选:PLY 保真、MP4 体积小、GLB 便于混合管线 |
案例:家具摆放应用
设想一个"先看再买"的家居应用:用户拍一段客厅视频,端侧重建成 3DGS 场景,再从商品库选沙发、茶几拖进去,看比例、看风格,满意了直接下单。
流程拆开看是这样:
#mermaid-svg-aykylbs17rFoV2Bv{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-aykylbs17rFoV2Bv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aykylbs17rFoV2Bv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aykylbs17rFoV2Bv .error-icon{fill:#552222;}#mermaid-svg-aykylbs17rFoV2Bv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aykylbs17rFoV2Bv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aykylbs17rFoV2Bv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aykylbs17rFoV2Bv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aykylbs17rFoV2Bv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aykylbs17rFoV2Bv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aykylbs17rFoV2Bv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aykylbs17rFoV2Bv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aykylbs17rFoV2Bv .marker.cross{stroke:#333333;}#mermaid-svg-aykylbs17rFoV2Bv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aykylbs17rFoV2Bv p{margin:0;}#mermaid-svg-aykylbs17rFoV2Bv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aykylbs17rFoV2Bv .cluster-label text{fill:#333;}#mermaid-svg-aykylbs17rFoV2Bv .cluster-label span{color:#333;}#mermaid-svg-aykylbs17rFoV2Bv .cluster-label span p{background-color:transparent;}#mermaid-svg-aykylbs17rFoV2Bv .label text,#mermaid-svg-aykylbs17rFoV2Bv span{fill:#333;color:#333;}#mermaid-svg-aykylbs17rFoV2Bv .node rect,#mermaid-svg-aykylbs17rFoV2Bv .node circle,#mermaid-svg-aykylbs17rFoV2Bv .node ellipse,#mermaid-svg-aykylbs17rFoV2Bv .node polygon,#mermaid-svg-aykylbs17rFoV2Bv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aykylbs17rFoV2Bv .rough-node .label text,#mermaid-svg-aykylbs17rFoV2Bv .node .label text,#mermaid-svg-aykylbs17rFoV2Bv .image-shape .label,#mermaid-svg-aykylbs17rFoV2Bv .icon-shape .label{text-anchor:middle;}#mermaid-svg-aykylbs17rFoV2Bv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aykylbs17rFoV2Bv .rough-node .label,#mermaid-svg-aykylbs17rFoV2Bv .node .label,#mermaid-svg-aykylbs17rFoV2Bv .image-shape .label,#mermaid-svg-aykylbs17rFoV2Bv .icon-shape .label{text-align:center;}#mermaid-svg-aykylbs17rFoV2Bv .node.clickable{cursor:pointer;}#mermaid-svg-aykylbs17rFoV2Bv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aykylbs17rFoV2Bv .arrowheadPath{fill:#333333;}#mermaid-svg-aykylbs17rFoV2Bv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aykylbs17rFoV2Bv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aykylbs17rFoV2Bv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aykylbs17rFoV2Bv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aykylbs17rFoV2Bv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aykylbs17rFoV2Bv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aykylbs17rFoV2Bv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aykylbs17rFoV2Bv .cluster text{fill:#333;}#mermaid-svg-aykylbs17rFoV2Bv .cluster span{color:#333;}#mermaid-svg-aykylbs17rFoV2Bv 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-aykylbs17rFoV2Bv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aykylbs17rFoV2Bv rect.text{fill:none;stroke-width:0;}#mermaid-svg-aykylbs17rFoV2Bv .icon-shape,#mermaid-svg-aykylbs17rFoV2Bv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aykylbs17rFoV2Bv .icon-shape p,#mermaid-svg-aykylbs17rFoV2Bv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aykylbs17rFoV2Bv .icon-shape .label rect,#mermaid-svg-aykylbs17rFoV2Bv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aykylbs17rFoV2Bv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aykylbs17rFoV2Bv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aykylbs17rFoV2Bv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 引导用户环绕拍摄
端侧重建 3DGS 场景
加载 GSNode 进入场景
按地面重建锚点平面
拖入 glTF 家具模型
Raycast 拾取 + 位移缩放
截图 / 录像分享
这个案例里,设计上最容易被忽略的是 D 这一步。重建出来的场景没有"地面"这个概念,是用户把家具放到半空中、还是贴到地上,全靠应用自己建立一个参考平面。常见做法是从点云的低处拟合一个水平面,或者让用户点两下地面手动定标。定标准不准,直接决定整个摆放体验可不可信。
第二个坑是拾取。用户觉得自己在点沙发,实际点的是空气------因为高斯点不参与拾取。所以每个家具都要挂一个不可见的包围盒 Node 作为代理,视觉上是 3DGS 家具,交互上命中的是代理盒。
几条小经验总结哦
- 插件注册是硬前提。
loadPlugin漏掉或顺序反了,后面所有 GSPlugin 调用都可能拿到未定义行为,而且不一定报错,排查起来很费时间。 - 采集质量直接决定上限。晃动、强反光、纯色墙面都是重建杀手,引导页要把"慢走、平稳、覆盖多角度"讲清楚。
- GSNode 是普通 Node,位移缩放照常用,但别指望它像 glTF 那样按子节点拆开编辑。
- 大场景一律先想 Tiled,不要等到内存爆了再改架构。
- 模拟器跑不了这个 Kit,早点上真机,别把时间花在环境上。
uri前缀写错。OhosRawFile://读的是 rawfile,file://读的是应用沙箱路径,两者不能混用。- 忘记判断
scene.root是否为空,后续as Node会直接崩。 - 把 Tiled 场景的相机忘了设置,渲染器只能默认用当前场景相机,分块选择可能不符合预期。
- 在瓦片回调里同步做网络请求,阻塞渲染线程;应该异步拉取、落盘后再
notifyTileReady。 - 采集数据和重建结果都放沙箱,没做清理策略,用几次就把用户存储占满。