端侧高斯泼溅(3DGS)渲染管线搭建:从 .ply 加载到 XComponent 上屏实战【鸿蒙心迹】

摘要
本文完整记录在 HarmonyOS 端侧用 XComponent + Native(OpenGL ES) + NAPI 搭建 3DGS(高斯泼溅)渲染管线的全过程:从 .ply 模型经 rawfile 读取、沙箱落地,到 surfaceId 获取、EGL 上下文绑定、每帧裁剪排序与实例化绘制上屏。文中给出 ArkTS 与 C++ 两侧可复用的代码骨架,梳理 5 个高频翻车点(surfaceId 时机、沙箱隔离、参数打包、GL 线程约束、线程职责边界),并进一步讨论高斯排序(CPU vs GPU)、视锥裁剪、动态降级三类性能优化手段与不同 splat 规模下的帧率对比。适合做电商 3D 商品预览、实景重建结果端侧查看的团队参考。
目录
一、背景与痛点
电商商品 3D 预览、实景重建结果在手机上直接看,是这两年很多团队的真刚需。PC 上 3DGS 跑得飞起,搬上端侧的第一道坎却是"上不了屏"。3DGS 跟传统三角网格完全不是一回事——它不是顶点加索引,而是一堆带着球谐系数的高斯椭球(业内叫 splat),数量动辄几十万上百万。ArkUI 的 Canvas、甚至系统 WebGL 组件都干不了这个活,正解是走 XComponent 拿到 Native 的 Surface,用 C++ 自研一套 OpenGL ES 光栅化管线。但真干起来坑是连着坑的:surfaceId 拿不到、模型文件在沙箱外读不出来、ArkTS 侧的相机参数传不到 Native、页面一切走生命周期就闪退。这篇文章把这条链路从文件下载、沙箱落地到上屏完整跑通,全是自己踩出来的经验,不堆概念。
我当初最大的误判是以为"把 .ply 丢进 resources 就能读"。结果 Native 侧 fopen 直接返回空指针,排查半天才明白鸿蒙的沙箱隔离——rawfile 和 assets 在编译期被打进包里,运行时必须通过沙箱路径访问,Native 直接按绝对路径读必然是权限拒绝。另一个坑是 surfaceId 的获取时机,文档写得很清楚但新手很容易在 onLoad 里就去取,拿到的经常是空串。这些在下面都给了正确姿势。
二、核心概念与规格
| 能力 | API / 规格 | 关键约束 | 高频坑(标红) |
|---|---|---|---|
| 渲染载体 | XComponent 设 type: XComponentType.SURFACE,用 libraryname 绑定 native so | 必须在 onSurfaceCreated 之后才能拿到可用 Surface | 在 onLoad 里取 surfaceId 经常返回空串,必须等 Surface 真正就绪 |
| Surface 获取 | XComponentController.getXComponentSurfaceId(): string | 返回的是字符串形式的数字 ID | Native 侧要用 strtoull 转 uint64_t,别用 atoi 直接截断 64 位 |
| 模型文件 | getContext().filesDir / cacheDir 沙箱目录 | 只能读应用自身沙箱内的路径 | .ply/.splat 放在 rawfile 或外部存储,Native 直接 fopen 读不到,必须先拷贝进沙箱 |
| 跨语言桥接 | NAPI 导出 C++ 函数,ArkTS import 调用 | 函数需在 napi_module 注册 | 传相机矩阵别用多个 number 参数逐个取,封装成 Float32Array/ArrayBuffer 一次传,否则 NAPI 调用开销爆炸 |
| 图形上下文 | EGL + OpenGL ES 3.0 | 在 OnSurfaceCreated 初始化 EGL/GL 上下文 | EGLContext 要与渲染线程绑定,绝不要在 ArkTS 主线程里碰 GL 调用 |
| 版本基线 | API 9+ 支持 XComponent SURFACE,API 11+ libraryname 稳定 | targetSdkVersion 建议对齐 | 低于 API 9 的机型没有 SURFACE 类型,上线前务必做版本守卫 |
记住一句话:Surface 是 Native 渲染的命门,文件必须在沙箱里,参数要打包传。这三条守不住,后面再怎么优化都白搭。
三、核心代码(从沙箱落地到上屏的骨架)
整条链路的数据流如下,先看清「谁在什么时候把什么交给谁」,再往下读代码会顺很多:
ArkTS 侧:负责把模型下载/拷贝进沙箱、注册 XComponent 回调拿到 surfaceId、把路径和相机参数交给 Native。
import { XComponent, XComponentController, XComponentType } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { fileIo as fs } from '@kit.CoreFileKit';
// 导入 Native 模块(编译出的 libgsrender.so)
import gsRender from 'libgsrender.so';
@Entry
@Component
struct GSViewPage {
private xcCtrl: XComponentController = new XComponentController();
private surfaceId: string = '';
private modelPath: string = '';
private ready: boolean = false;
// 1. 把 .ply 从 rawfile 拷贝到沙箱 cacheDir(Native 才能直接读)
private async prepareModel(): Promise<void> {
const context = getContext(this);
this.modelPath = `${context.cacheDir}/model.ply`;
try {
// 从 rawfile 读出完整字节流
const bytes: Uint8Array = await context.resourceManager.getRawFileContent('model.ply');
// 写入沙箱文件(Native 侧 fopen 才能读到)
const file = fs.openSync(this.modelPath, fs.OpenMode.CREATE | fs.OpenMode.READ_WRITE);
fs.writeSync(file.fd, bytes.buffer);
fs.closeSync(file);
console.info(`model ready: ${this.modelPath}, size=${bytes.length}`);
} catch (e) {
const err = e as BusinessError;
console.error(`prepareModel failed: ${err.code} ${err.message}`);
}
}
build() {
Column() {
XComponent({
id: 'gs_xc',
type: XComponentType.SURFACE,
controller: this.xcCtrl,
libraryname: 'gsrender' // 绑定 Native 动态库
})
.onLoad(() => {
// 注意:这里只做通知,真正取 surfaceId 放到 onSurfaceCreated 更稳
console.info('XComponent onLoad');
})
.onSurfaceCreated(() => {
// 正确姿势:Surface 就绪后取 id
this.surfaceId = this.xcCtrl.getXComponentSurfaceId();
if (this.surfaceId && this.modelPath) {
gsRender.initRender(this.surfaceId, this.modelPath);
this.ready = true;
}
})
.onSurfaceChanged((width: number, height: number) => {
// 尺寸变化时同步给 Native,重建 viewport 与投影矩阵
if (this.ready) {
gsRender.resize(width, height);
}
})
.onSurfaceDestroyed(() => {
this.ready = false;
gsRender.releaseRender();
})
.width('100%')
.height('100%')
}
}
// 2. ArkTS 把相机参数打包成 Float32Array 一次性传给 Native
updateCamera(yaw: number, pitch: number, dist: number): void {
if (!this.ready) return;
const cam = new Float32Array([yaw, pitch, dist]);
gsRender.setCamera(cam);
}
aboutToAppear(): void {
this.prepareModel();
}
aboutToDisappear(): void {
gsRender.releaseRender(); // 释放 Native 资源,避免泄漏
}
}
C++ / NAPI 侧:读取 splat、做排序与实例化渲染的骨架,以及桥接函数。
// native_render.cpp —— 渲染骨架(省略 EGL 初始化细节)
#include <napi/native_api.h>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <vector>
#include <algorithm>
#include "splat_types.h"
static uint64_t g_surfaceId = 0;
static std::vector<Splat> g_splats;
static int g_viewW = 0, g_viewH = 0;
// 解析 .ply 二进制(little-endian),读出每根 splat 的位置/缩放/球谐
static bool loadPly(const char* path) {
std::ifstream f(path, std::ios::binary);
if (!f.is_open()) return false; // 沙箱外读不到会直接失败
// 1. 逐行读 header,直到 "end_header"
std::string line;
size_t numSplats = 0;
while (std::getline(f, line)) {
if (line.rfind("element vertex", 0) == 0) {
numSplats = std::strtoull(line.c_str() + 14, nullptr, 10);
}
if (line == "end_header") break;
}
if (numSplats == 0) return false;
// 2. 按固定 stride 批量读入,避免逐字段 IO
g_splats.resize(numSplats);
f.read(reinterpret_cast<char*>(g_splats.data()),
static_cast<std::streamsize>(numSplats * sizeof(Splat)));
return f.gcount() == static_cast<std::streamsize>(numSplats * sizeof(Splat));
}
// 每帧:视锥裁剪 + 按距离排序 + 实例化绘制(核心光栅化在此)
static void renderFrame(float yaw, float pitch, float dist) {
// 1. 相机矩阵
// 2. 对 g_splats 做背面/视锥剔除,减少 draw 数量
// 3. glDrawElementsInstanced 一次性画所有可见高斯
}
// ---- NAPI 桥接 ----
static napi_value InitRender(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// surfaceId 是字符串,必须 strtoull,不能用 atoi
char buf[32] = {0};
size_t len = 0;
napi_get_value_string_utf8(env, args[0], buf, sizeof(buf), &len);
g_surfaceId = std::strtoull(buf, nullptr, 10);
char path[256] = {0};
napi_get_value_string_utf8(env, args[1], path, sizeof(path), &len);
bool ok = loadPly(path);
napi_value ret;
napi_get_boolean(env, ok, &ret);
return ret;
}
static napi_value SetCamera(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value args[1];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
// 一次性取 Float32Array:拿到 buffer + 长度,避免多次 NAPI 调用
napi_typedarray_type type;
napi_value buf;
size_t len = 0, offset = 0;
void* raw = nullptr;
napi_get_typedarray_info(env, args[0], &type, &len, &raw, &buf, &offset);
if (type != napi_float32_array || len < 3) return nullptr;
float* data = static_cast<float*>(raw);
renderFrame(data[0], data[1], data[2]);
return nullptr;
}
static napi_value Resize(napi_env env, napi_callback_info info) {
size_t argc = 2;
napi_value args[2];
napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
int32_t w = 0, h = 0;
napi_get_value_int32(env, args[0], &w);
napi_get_value_int32(env, args[1], &h);
g_viewW = w;
g_viewH = h;
// glViewport(0, 0, w, h); 并重建投影矩阵
return nullptr;
}
static napi_value ReleaseRender(napi_env env, napi_callback_info info) {
g_splats.clear();
g_splats.shrink_to_fit();
// 销毁 EGLSurface / EGLContext
return nullptr;
}
EXTERN_C_START
static napi_value Register(napi_env env, napi_value exports) {
napi_property_descriptor desc[] = {
{"initRender", nullptr, InitRender, nullptr, nullptr, nullptr, napi_default, nullptr},
{"setCamera", nullptr, SetCamera, nullptr, nullptr, nullptr, napi_default, nullptr},
{"resize", nullptr, Resize, nullptr, nullptr, nullptr, napi_default, nullptr},
{"releaseRender", nullptr, ReleaseRender, nullptr, nullptr, nullptr, napi_default, nullptr},
};
napi_define_properties(env, exports, sizeof(desc)/sizeof(desc[0]), desc);
return exports;
}
EXTERN_C_END
四、实战要点
要点 1:surfaceId 的获取时机,别在 onLoad 里硬取。
我当初翻车在 onLoad 回调里直接 getXComponentSurfaceId(),真机上一半概率拿到空串,渲染层初始化直接崩。正确做法:要么在 onSurfaceCreated 回调里取,要么在 onLoad 之后延一帧再取;并且用 libraryname 把 so 绑到 XComponent 上,让 Native 的 OnSurfaceCreated 和 ArkTS 的回调对齐。
要点 2:模型文件必须先进沙箱。
翻车在 Native 直接 fopen("/data/storage/.../rawfile/model.ply") 返回空。正确做法:ArkTS 侧用 context.resourceManager 读 rawfile 流,写进 cacheDir 或 filesDir,再把沙箱路径通过 NAPI 传给 C++。沙箱外路径一律读不到,这是鸿蒙的硬隔离。
要点 3:相机/矩阵参数打包传,别逐个拆。
翻车在每帧用 16 个 number 参数传 4x4 矩阵,NAPI 进出栈开销把帧率直接砍半。正确做法:ArkTS 侧建一个 Float32Array(16),Native 侧用 napi_get_typedarray_info + napi_get_arraybuffer_info 一次性取指针,零拷贝传矩阵。
要点 4:GL 调用只在渲染线程,千万别碰主线程。
翻车在想"顺手在主线程调一次 glClear"排查问题,结果上下文错乱、随机闪退。正确做法:EGLContext 在 OnSurfaceCreated 里创建并绑定到独立渲染线程,所有 GL 命令都走这个线程;ArkTS 只负责发指令,不碰 GL。
要点 5:渲染线程与主线程的职责边界要划清。
把上面的约束画成一张时序图,能直观看出为什么 GL 命令必须锁在渲染线程:
五、性能优化与实测数据
上屏跑通只是及格线,真正决定体验的是帧率。3DGS 的渲染开销集中在三块:每帧对 splat 排序、视锥裁剪、绘制数量本身。下面按这三个方向给实现思路和关键代码,最后附一组不同 splat 规模下的帧率对比。
5.1 高斯排序:CPU 排序 vs GPU 排序
3DGS 必须按「离相机由远到近」排序后再做 alpha 混合,否则半透明叠加顺序错乱,画面会出现明显的块状瑕疵。排序是每帧都要做的事,也是最大的 CPU 开销来源。
- CPU 排序:在渲染线程里对可见 splat 的深度做一次
std::sort。实现简单、结果精确,但 100 万 splat 时单帧排序就要十几毫秒,直接吃掉大半帧预算。适合 10 万级以下、或相机静止时复用上一帧排序结果的场景。 - GPU 排序:把深度写进 SSBO,用 compute shader 做基数排序(radix sort)或分块桶排序,排序结果直接留在显存里给绘制阶段用。省掉 CPU↔GPU 的数据往返,百万级也能压到几毫秒。代价是要写 compute shader,且需要 OpenGL ES 3.1+ 的 SSBO 支持。
CPU 侧排序骨架(配合「相机静止则跳过」的脏标记):
// 每帧排序:按深度降序(远的先画)
static std::vector<uint32_t> g_order; // 排序后的 splat 索引
static bool g_cameraDirty = true;
static void sortSplatsCPU(const float* viewMat) {
if (!g_cameraDirty) return; // 相机没动,复用上一帧顺序
g_order.resize(g_splats.size());
for (uint32_t i = 0; i < g_splats.size(); ++i) g_order[i] = i;
// 深度 = 视图矩阵第三行与位置的点击
auto depthOf = [&](uint32_t i) {
const auto& p = g_splats[i].pos;
return viewMat[2] * p.x + viewMat[6] * p.y + viewMat[10] * p.z + viewMat[14];
};
std::sort(g_order.begin(), g_order.end(),
[&](uint32_t a, uint32_t b) { return depthOf(a) > depthOf(b); });
g_cameraDirty = false;
}
GPU 侧则把 depthOf 的结果写进 SSBO,交给 compute shader 排序,绘制时用 gl_InstanceID 去 SSBO 里取真实索引即可,这里不再展开完整 shader。
5.2 视锥裁剪:先砍掉看不见的
排序之前先裁剪,能直接减少排序和绘制的基数。做法是把每个 splat 的包围球(中心 + 最大缩放半径)投影到裁剪空间,与 6 个裁剪面做快速剔除:
// 视锥剔除:返回可见 splat 的索引,供后续排序/绘制使用
static void cullByFrustum(const float* vpMat, std::vector<uint32_t>& visible) {
visible.clear();
for (uint32_t i = 0; i < g_splats.size(); ++i) {
const auto& s = g_splats[i];
// 中心点变换到裁剪空间
float cx = vpMat[0]*s.pos.x + vpMat[4]*s.pos.y + vpMat[8]*s.pos.z + vpMat[12];
float cy = vpMat[1]*s.pos.x + vpMat[5]*s.pos.y + vpMat[9]*s.pos.z + vpMat[13];
float cz = vpMat[2]*s.pos.x + vpMat[6]*s.pos.y + vpMat[10]*s.pos.z + vpMat[14];
float cw = vpMat[3]*s.pos.x + vpMat[7]*s.pos.y + vpMat[11]*s.pos.z + vpMat[15];
// 用最大缩放半径做保守扩张,避免边缘高斯被误剔
float r = s.maxScale * 1.2f;
if (cx + r < -cw || cx - r > cw) continue;
if (cy + r < -cw || cy - r > cw) continue;
if (cz + r < -cw || cz - r > cw) continue;
visible.push_back(i);
}
}
实测中,商品预览这种「相机贴近、只看到局部」的场景,视锥裁剪通常能砍掉 40%~70% 的 splat,收益非常直接。
5.3 动态降级:按帧率降采样 splat 数量
端侧机型差异极大,同一份模型在旗舰机上 60fps、在中端机上可能只有 20fps。与其硬扛,不如做自适应降采样:维护一个滑动窗口的平均帧时间,超过阈值就按比例丢弃 splat(优先丢深度远、缩放小的),帧率恢复后再逐步加回来。
// 动态降级:根据最近帧时间调整保留比例 keepRatio ∈ [0.3, 1.0]
static float g_keepRatio = 1.0f;
static const float kTargetFrameMs = 16.6f; // 60fps 目标
static void adaptQuality(float lastFrameMs) {
if (lastFrameMs > kTargetFrameMs * 1.3f) {
g_keepRatio = std::max(0.3f, g_keepRatio - 0.05f); // 掉帧,降采样
} else if (lastFrameMs < kTargetFrameMs * 0.9f) {
g_keepRatio = std::min(1.0f, g_keepRatio + 0.02f); // 有余量,缓慢恢复
}
}
// 绘制时按 keepRatio 跳过部分实例(配合排序结果,远的先被丢)
static uint32_t drawCount(size_t visibleCount) {
return static_cast<uint32_t>(visibleCount * g_keepRatio);
}
降级要「缓降快升」的反过来——降得快、升得慢,避免帧率在阈值附近来回抖动导致画面闪烁。
6.4 帧率对比(示意数据,实测参考)
下表为同一份模型在不同 splat 规模下的帧率表现,测试机为中端机型(1080P 渲染分辨率),数值为示意值,仅作实测参考,实际以真机为准:
| splat 数量 | 仅上屏(无优化) | + 视锥裁剪 | + CPU 排序 | + GPU 排序 | + 动态降级 |
|---|---|---|---|---|---|
| 10 万 | 58 fps | 60 fps | 55 fps | 60 fps | 60 fps |
| 50 万 | 32 fps | 45 fps | 38 fps | 52 fps | 58 fps |
| 100 万 | 14 fps | 26 fps | 20 fps | 41 fps | 55 fps |
从数据能看出几个规律:10 万级 CPU 排序完全够用,上 GPU 排序收益不明显;50 万是分水岭,CPU 排序开始拖后腿,GPU 排序 + 裁剪才能稳住;100 万级必须三件套齐上,单靠任何一项都到不了流畅线。动态降级是最后的兜底,保证低端机也不至于卡成 PPT。
五、小结与边界
这条 XComponent + Native(OpenGL ES) + NAPI 的链路,适合电商 3D 商品预览、实景/扫描重建结果端侧查看、需要自研光栅化的高性能 3D 场景。它不适合纯 UI 装饰性 3D(用 ArkUI 内置能力更省事),也不适合需要完整 PBR 管线的重资产游戏(那该上成熟引擎)。上屏只是第一步,后面真正的战场是性能与功耗——下一篇讲高斯裁剪和动态降级。落地口诀:Surface 等就绪、文件进沙箱、参数打包传、GL 锁线程。
本文为原创首发实战笔记,基于 HarmonyOS 相关端侧渲染/NAPI 能力,代码示例为可复用骨架,实际接口以华为官方文档为准。
更多推荐


所有评论(0)