ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android点云与3DGS渲染实战:基于Filament的EQR项目复盘

Android点云与3DGS渲染实战:基于Filament的EQR项目复盘 2026 年开工第一件事是把改了三轮的 Sceneform-EQR 拉到骁龙机器上又完整跑了一遍。这个项目最初是给一台结构光扫描仪做点云预览后来 RealSense D435 的点云流也接进来再后来 3D Gaussian Splatting 被身边朋友反复安利于是我在 Filament 渲染层上把 PLY 点云 / Mesh 支持做扎实之后又开始折腾高斯泼溅的渲染实现。这篇文章不是教程是项目复盘把我踩过的坑和现在能跑通的方案都摊开说一遍。如果你正在做 Android 点云可视化、AR 扫描预览或者被 3DGS 移动端落地折磨这篇应该有点用。1. 为什么还要捡起 SceneformEQR 要补的短板1.1 Sceneform 的债好上手但留了太多“接口黑洞”Sceneform 这个框架熟悉 Android AR 开发的应该都有印象。它本意是让开发者用最简单的方式把 3D 模型放进 AR 场景早期版本基于 OpenGL ES1.15 之后底层换成了 Filament。听起来很美好场景图、资源加载、材质系统、灯光阴影都帮你包好了你只需要调用Renderable.builder()加载一个模型再挂到Node上就完事。问题恰恰出在这个“包好了”上面。等你想往里塞自定义几何数据的时候会发现它把底层收得太紧。我记得最早想把点云喂进去需要自己创建 Filament 的VertexBuffer再包成Renderable但 Sceneform 的Node系统并没有直接暴露这层操作。用反射绕开的话又会遇到 Engine 生命周期管理的问题——Sceneform 启动了一次Engine你很难再把同一个 Engine 实例拿出来做底层的顶点缓冲更新。说白了它适合“加载一个 sfb/glb 再显示”不适合“把自定义几何数据变成场景里的可交互物体”。所以后来我直接在项目里引了 Filament 的依赖不再走 Sceneform 的资源加载那套只保留 Sceneform 的交互思想和命名习惯。EQR 这个名字就是我们内部的项目代号我给它加了个渲染层专门用于动态点云和网格数据。1.2 EQR 到底解决什么问题EQR 要做的事情说简单也简单把 PLY 点云和 Mesh 从磁盘文件或相机流里读出来按固定帧率塞给 Filament 显示支持旋转、缩放、平移并且能在点云和 Mesh 之间一键切换。使用场景很直接——扫描过程中实时看到点云在增长扫描完可以把重建的 Mesh 叠到点云上检查质量。这种工具需求在算法验证阶段特别常见。结构光相机扫描出来的点云往往是没有颜色的配合 D435 这类深度相机可以拿到 RGB-D 数据但不同来源的点云格式差异很大。EQR 的定位不是做一个通用查看器而是一个够快的“验证台”。我在调试多视角点云配准的时候需要反复切换两个点云图层看对齐残差还要随时把算法输出的 Mesh 拿出来和原始点云叠加比对。如果每个数据格式都写一个查看器那我的时间全耗在 UI 上了不如统一到同一个渲染链路里。1.3 为什么最终停在 Filament很多人问我为什么不直接用 OpenGL ES 写点云渲染答案是可以写但运维成本太高。移动 GPU 驱动差异大EGL 线程管理、着色器跨设备兼容、FBO 切换、矩阵变换一套写下来维护成本极高。用 Vulkan 更底层Android 机型碎片化严重光 driver bug 就够喝一壶。Filament 正好把这一层封装好了同一套 API 可以切换 Vulkan / OpenGL ES / Metal 后端还自带 PBR 材质、阴影、后处理管线并且支持自定义 unlit 材质。点云渲染最需要的不是光照而是快速更新顶点缓冲、大数量顶点绘制、可控深度。这些都是 Filament 的基本功。另外Sceneform 本身就是基于 Filament 的所以从 Sceneform 切到直接使用 Filament 并不是放弃原来的资产反而更接近渲染引擎本体。结论很简单在 Android 上做自定义几何可视化与其跟 Sceneform 的 API 较劲不如直接使用它底层的那个渲染引擎。2. Filament 点云渲染不是简单倒入顶点就行2.1 顶点缓冲布局显式交错和位宽选择Filament 渲染点云的第一步是创建VertexBuffer。这里有个关键认知Filament 不会自动帮你解释“点”这种图元类型但它允许你自定义顶点属性布局。我的做法是把点云数据做成一个交错布局的顶点缓冲每个点占 16 字节vec3坐标用FLOAT3颜色用UBYTE4剩下的 4 字节里 3 个给 RGB一个补 alpha。val vertexBuffer VertexBuffer.Builder() .vertexCount(pointCount) .bufferCount(1) .attribute(VertexAttribute.POSITION, 0, AttributeType.FLOAT3, 0, 16) .attribute(VertexAttribute.COLOR, 0, AttributeType.UBYTE4, 12, 16) .build(engine) vertexBuffer.setBufferAt(engine, 0, byteBuffer)这里面有两个容易踩的坑第一AttributeType.FLOAT3之后属性偏移一定要对齐。如果前面放的是FLOAT3后面放UBYTE4总跨距直接算 16 字节就够了但如果中间还加了一个FLOAT或者SHORT跨距就不是 16必须按实际 sizeof 精确给值否则在部分 GPU 上会出现花点或者错位。第二颜色属性用UBYTE4但 PLY 文件里颜色经常是uchar三通道没有 alpha。如果你的点云颜色本身是 BGR 排布解析的时候要转成 RGB否则显示出来是红的蓝的颠倒。很多扫描仪导出的点云颜色就是 BGR是个非常隐蔽的坑。2.2 没有点精灵我用 billboard 四边形硬顶最开始我想偷懒直接用类似 OpenGL 里的点精灵方式渲染点云。Filament 材质语言里确实可以给顶点着色器设置gl_PointSize但我实测下来兼容性并不好Vulkan backend 下点精灵支持不稳定点大小超过一定阈值直接不显示Mali 系 GPU 的 OpenGL ES 驱动也有抽风情况。与其跟驱动斗智斗勇不如老老实实把每个点展开成一个面朝相机的四边形billboard。实现思路是在 CPU 端把点云从点集展开成顶点集。每个点变成 4 个顶点分别对应四边形左上、左下、右下、右上索引缓冲每 4 个顶点配 6 个索引两个三角形。顶点着色器里只需要做常规 MVP 变换片元着色器再根据圆心距离计算圆形 alpha 裁剪这样显示出来就是边缘平滑的点。所以渲染一个 100 万点的点云实际提交的顶点是 400 万索引是 600 万条。内存上顶点缓冲 400 万 × 16 字节 64MB对于移动端来说压力已经很大了。这也是为什么点云必须先做抽稀后面我会专门写一节。方案对比我整理过一张表方案顶点数兼容性备注原生点精灵1 倍差Vulkan/GLES 驱动表现不一致大小受限容易踩驱动的坑billboard 四边形4 倍好所有后端一致本文采用方案内存开销大但稳定三角片元圆形1 倍需要几何着色器或特殊技巧Filament 不支持几何着色器放弃2.3 材质、颜色和深度unlit 材质反而更省心点云的颜色通常不需要光照参与。扫描仪输出的就是 RGB 值你希望屏幕上看到的就是原始颜色因此材质一定要用unlitshading model。Lighting 模式全部关掉材质里直接把顶点颜色赋给 baseColor。Filament 材质写法大致是void material(inout MaterialInputs material) { prepareMaterial(material); material.baseColor vec4(getVertexColor().rgb, 1.0); }不同版本 API 名字略有差异有的用diffuseColor照 IDE 补全就行。这里有一个容易被忽略的点Filament 默认会把顶点颜色当成 sRGB 并做线性化处理。普通扫描仪输出的点云颜色未必是规范 sRGB如果显示出来颜色偏淡可以检查材质里是否需要绕过颜色空间转换。我在某个结构光软件导出的点云上就遇到过这个问题颜色整体偏灰后来是在 shader 里直接用原始颜色绕过了线性转换。深度设置也有讲究。点云一般希望被 Mesh 遮挡所以深度测试要开但如果你需要半透明点云来观察前后遮挡关系就涉及透明混合了。同一帧内 Filament 对透明的处理按物体排序一个 billboard mesh 内部的图元顺序不一定保证 painters algorithm所以半透明点云在实际渲染时会出现层叠顺序错乱。我的经验是点云最好渲染成不透明的如果要透明对比就做两个图层各自不透明通过图层整体透明度混合也不要在单个点云 mesh 内部做 alpha blend。3. PLY 解析与 Mesh 重建数据链路里的坑3.1 ASCII 与二进制 PLY 的取舍PLY 格式有ascii、binary_little_endian、binary_big_endian三种编码。如果你的工具只支持 ASCII强烈建议在项目里加一个转换步骤否则面对几百 MB 的文本文件解析速度会拖垮内存和 CPU。EQR 里我只实现了binary_little_endian遇到 ASCII 文件会统一转成二进制再处理。一个合格的 PLY 解析器至少要处理这么几个部分头部element vertex和element face声明了顶点和面数后面的property列表决定了数据布局。顶点属性x、y、z通常在最前面但并不是所有文件都遵守有些工具会把法线或者颜色属性插在中间不能按固定偏移读必须按属性名匹配。颜色类型可能是uchar red/green/blue也可能是float red/green/blue格式五花八门。遇到 float 颜色要转成 0~255 的 uchar否则UBYTE4装不下。多余的 normal、alpha、quality 属性可以忽略但读取时要跳过对应的字节否则数据流会错位。解析代码的伪码思路如下fun parseVertexHeader(header: ListString): ListVertexProperty { // 按 property 名字收集 x/y/z 和 red/green/blue 的字节偏移与类型 } // 然后按顶点字节大小循环读取填充 float/uchar 数组3.2 Mesh 索引三角化与法线补算当 PLY 文件里有element face时就是网格数据。常见的 face 声明写法是property list uchar int vertex_indices意思是每个 face 先读一个 uchar 表示该面有几个顶点再依次读 int 索引。多数扫描重建工具导出的是三角形直接填索引即可但偶尔会碰到多边形面。遇到多边形时我用最简单的扇形三角化取第一个顶点为公共点剩余顶点按顺序组成若干三角形。对于普通重建网格来说足够。Filament 的IndexBuffer有 16-bit 和 32-bit 两种索引类型。顶点数超过 65535 时必须用 32 位否则渲染直接崩溃或者花屏。这个坑特别容易在点云和 Mesh 的边界场景踩到点云抽稀后顶点数可能少于 65535但如果你把原始稠密点云直接构造成网格索引就很容易超。务必在构建IndexBuffer前检查顶点数量动态选择IndexType.USHORT还是IndexType.UINT。Mesh 如果要显示光照阴影顶点法线是必须的。PLY 文件不一定带 normal 属性因此我在加载阶段做一次法线补算遍历所有三角形累加面法线到三个顶点最后对每个顶点法线归一化。注意如果 PLY 本身带了 normal 属性但法线朝向不一致渲染结果会出现明暗断痕可以通过渲染单色模式检查。3.3 体素抽稀与包围盒归一化移动端真不是拿来显示千万级点云的地方。我测试过的某地形点云文件一共 8000 万点解析完原始顶点缓冲就要 1.2GB直接 OOM。所以点云加载链路里必须有一层抽稀。我采用的是体素网格下采样VoxelGrid把点云空间按设定的尺寸切成小立方体每个格子保留一个离中心最近的点这样既保留了形状特征又避免随机抽稀产生的空洞和密度不均匀。体素尺寸一般结合场景复杂度设置。比如 D435 扫描头部模型体素 1mm 足够地形点云配准可能需要 10cm 甚至 1m。抽稀之后再做一次包围盒归一化计算 AABB把中心移到原点然后按最长边缩放。这个步骤特别重要因为很多扫描仪导出的点云坐标是真实世界尺度数值可能达到千米级别直接用 float 存储会出现浮点精度损失渲染时还会出现相机飞过头顶的尴尬情况。归一化后点云坐标落在近原点范围显式精度和渲染手感都好很多。4. 3D Gaussian Splatting 渲染实验从点云到椭球光栅化4.1 Splat 到底是啥协方差、球谐和透明度混合3D Gaussian Splatting 是这两年视觉领域最热的渲染表示之一。它的基本思路是场景不是用三角网格表示而是一堆各向异性的三维高斯核。每个 splat 有一个中心坐标、一个协方差矩阵由旋转和缩放决定、一个不透明度以及一组球谐系数用于视角相关颜色。渲染的时候把三维高斯投影到二维屏幕空间得到一个椭圆然后按深度从近到远排序逐个叠加 alpha 混合。这个思路和点云渲染有天然亲和力点云可以当作高斯 splat 的初始化位置颜色可以直接作为 splat 的基础颜色初始协方差设成各向同性的一个小球。训练阶段通过梯度下降不断调整 splat 的位置、大小、旋转和不透明度最终收敛出一个高质量的可实时渲染场景。所以 3DGS 不是“把点云变大”而是把一个连续密度场用大量半透明粒子拟合出来。4.2 三条实现路线预排序、OIT 和 compute 排序在 Filament 里做 3DGS 渲染最大的难点是透明度排序。我先后考虑过三条路线。路线一CPU 预排序 billboard 绘制。每帧根据相机视角计算所有 splat 中心到相机平面的深度在 CPU 上做一次排序然后按排序后的顺序生成顶点缓冲和索引缓冲提交给 GPU。这个方案实现简单在 splat 数量不超过 10 万时排序耗时大约 5~8ms可以接受。但点数达到百万级时CPU 排序的开销就会吃掉整帧预算。路线二OITOrder-Independent Transparency。用加权混合或者每像素链表的方式不依赖顺序也能得到正确结果。Filament 目前对自定义 OIT 支持并不友好材质层和混合状态的选择有限实现复杂度高而且加权混合在边缘处容易产生光晕和透明度错误。作为实验可玩落地不划算。路线三compute shader 排序 indirect draw。把 splat 的深度计算和排序放到 GPU compute shader 里生成顺序写入显存再通过 indirect drawing 一次性绘制全部四边形。这是效率最高的做法但 Filament 的 compute shader 支持还不完整尤其在 OpenGL ES 后端有限制必须上 Vulkan backend 才能稳定跑。这也是我目前正在攻克的方向。三条路线对比如下路线实现成本性能上限稳定性CPU 预排序 billboard低10 万 splat 30~60fps高驱动无差异OIT 加权混合高50 万 splat 但质量受损中存在边缘瑕疵GPU compute 排序很高100 万 splat 60fps依赖 Vulkan backend4.3 现阶段能跑的版本预排序 splat 渲染管线EQR 当前实现的是路线一。加载一个训练好的 3DGS PLY 文件后我会解析出每个 splat 的 pos、scale、rot、opacity 和 SH 系数。这里提醒一句原始 3DGS 训练代码导出的 PLY 属性顺序非常迷不仅有x/y/z还有f_dc_0/1/2、f_rest_*、opacity、scale_*、rot_*以及n/an1/an2这类额外属性。解析时一定要用属性名匹配不能按固定偏移读否则 order 一乱整个 splat 全错。我在解析这里栽过好几次跟头。渲染时每帧更新 camera view然后对全部 splat 做 CPU 排序。对于少于 5 万的 splat这个循环很快对于 10 万 splat大概是 6~8ms配合渲染可以维持 30fps。顶点缓冲会根据排序结果重建因此 Fragment shader 里直接按顺序做 alpha 混合可以得到相对正确的层次关系。材质方面一个比较 hacky 的地方是Filament 的混合模式需要设成 TRANSPARENT同时关闭 depth write。因为我们在 CPU 端已经排好序提交给 GPU 的顶点顺序就是 painters algorithm 期望的顺序所以同一个 Renderable 内部可以保证混合顺序。这个前提是每一帧都重建 vertex buffer每一帧的顶点顺序都是排序后的顺序不能复用上一帧的缓冲。目前这套实现能渲染 4K 分辨率的 3DGS 模型但帧率感人。优化方向也已经明确把深度排序从 CPU 搬到 GPU配合 Vulkan backend 的 compute shader才有可能在移动端实现 100 万 splat 的实时渲染。5. 实测帧率、内存账和后续方向5.1 D435 点云在 EQR 里的表现RealSense D435 是很多做点云获取的入门首选它输出 640×480 的深度图对齐彩色图后转成点云有效点大概 30 万左右。过滤掉无效深度之后通常剩下 20~25 万点。把这 20 多万点用 billboard 不透明方式渲染在骁龙 8 Gen 2 设备上帧率能稳定在 50~60fps内存占用其实不大——30 万点 × 16 字节顶点缓冲不到 5MB。实际操作中真正的瓶颈来自两处一是深度图到点云的转换过程如果每个点都用一个java.nio.ByteBuffer再做 float 转换GC 会非常频繁所以我把 D435 的深度 u16 数据直接映射到 native 层转换同时复用 ByteBuffer。二是持续的流式点云更新每帧重新setBufferAt会造成 GPU 等待因此我用双缓冲一个缓冲正在渲染另一个缓冲写入下一帧点云。5.2 内存与外存顶点流压缩与异步加载PLY 文件动辄几百 MB不能一口气读进内存。我的加载链路是BufferedInputStream流式读取头部和逐条顶点数据解析出来的顶点先存入中间数组然后体素抽稀再把抽稀后的结果一次性写入DirectByteBuffer并交给 Filament。解析一个 300MB 的二进制 PLY大约需要 1.5~2 秒再加抽稀和构建 VBO整体能控制在 300ms 内。这是在 JVM heap 内直接解析的最快方案再往下优化只能走 mmap。对于“边扫描边预览”的场景我需要控制每帧新增点数的上限。做法是解析线程把新点云写入预分配数组并通知渲染线程渲染线程每帧只消费固定数量比如 5 万个点来更新缓冲避免一帧内大量拷贝导致掉帧。这个策略在结构光扫描仪连续拼接场景下表现很好。5.3 下一步配准、图像引导融合和 3DGS 落地EQR 后续要打通三个典型场景。第一个场景是结构光相机多视角点云融合。多个视角扫描的点云需要先粗略对齐到同一个坐标系然后在 EQR 里叠加显示通过颜色分层看配准残差。现在 EQR 已经支持加载两个点云图层并分别设置颜色和不透明度。第二个场景是图像引导点云。也就是用 RGB 图像给点云上色类似 D435 的align_to_color模式。这个功能本身不复杂但要注意颜色空间一致性和时间戳对齐否则纹理映射会错位。第三个场景是地形点云配准。无人机影像生成的地形点云和地面扫描点云在尺度、旋转和平移上差异很大需要先做粗配准再到 EQR 里做微调预览。目前我能做到把两个大地坐标点云都归一化到原点然后手动粗配准但还不够自动化。至于 3DGS我给自己定的目标是 Android 上 100 万 splat 跑到 60fps。要达成这个目标必须把排序搬到 Vulkan compute shader 上还要解决 splat 球谐系数解码的计算量。等 EQR 把这套流程跑通我计划把整个 Python 端的 PLY 预处理、训练初始化和 Android 端渲染加载整理出来做成一套开源工具链。如果你也想在 Filament 里做点云或 3DGS 渲染我有三个建议一是尽量不要用点精灵老老实实 billboard兼容性决定你半夜要不要爬起来改 bug二是所有解析代码都按属性名匹配别相信固定偏移三是先做好体素抽稀和包围盒归一化很多渲染问题其实是数据规模和大坐标精度引起的跟渲染引擎没关系。踩过这些坑后面的路会顺利很多。
返回列表