在这里插入图片描述

摘要

本文完整记录在 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返回的是字符串形式的数字 IDNative 侧要用 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 渲染的命门,文件必须在沙箱里,参数要打包传。这三条守不住,后面再怎么优化都白搭。

三、核心代码(从沙箱落地到上屏的骨架)

整条链路的数据流如下,先看清「谁在什么时候把什么交给谁」,再往下读代码会顺很多:

rawfile: model.ply

ArkTS: resourceManager 读字节流

写入沙箱 cacheDir/model.ply

XComponent 创建 SURFACE

onSurfaceCreated 取 surfaceId

NAPI initRender(surfaceId, path)

C++: loadPly 解析 splat

EGL/GL 上下文绑定渲染线程

每帧: 裁剪 + 排序 + 实例化绘制

ArkTS: Float32Array 相机参数

NAPI setCamera

上屏显示

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 命令必须锁在渲染线程:

"EGL/OpenGL ES" "Native 渲染线程" "XComponent" "ArkTS 主线程" "EGL/OpenGL ES" "Native 渲染线程" "XComponent" "ArkTS 主线程" "创建 SURFACE" "OnSurfaceCreated 回调" "创建 EGLContext 并绑定" "setCamera(Float32Array)" "glDrawElementsInstanced" "提交帧缓冲" "上屏" "releaseRender()" "销毁上下文"

五、性能优化与实测数据

上屏跑通只是及格线,真正决定体验的是帧率。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 fps60 fps55 fps60 fps60 fps
50 万32 fps45 fps38 fps52 fps58 fps
100 万14 fps26 fps20 fps41 fps55 fps

从数据能看出几个规律:10 万级 CPU 排序完全够用,上 GPU 排序收益不明显;50 万是分水岭,CPU 排序开始拖后腿,GPU 排序 + 裁剪才能稳住;100 万级必须三件套齐上,单靠任何一项都到不了流畅线。动态降级是最后的兜底,保证低端机也不至于卡成 PPT。

五、小结与边界

这条 XComponent + Native(OpenGL ES) + NAPI 的链路,适合电商 3D 商品预览、实景/扫描重建结果端侧查看、需要自研光栅化的高性能 3D 场景。它不适合纯 UI 装饰性 3D(用 ArkUI 内置能力更省事),也不适合需要完整 PBR 管线的重资产游戏(那该上成熟引擎)。上屏只是第一步,后面真正的战场是性能与功耗——下一篇讲高斯裁剪和动态降级。落地口诀:Surface 等就绪、文件进沙箱、参数打包传、GL 锁线程。


本文为原创首发实战笔记,基于 HarmonyOS 相关端侧渲染/NAPI 能力,代码示例为可复用骨架,实际接口以华为官方文档为准。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐