ARTICLE DETAIL

资讯详情

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

移动端3D渲染内核换血:Filament驱动下的PLY点云与3DGS实践

移动端3D渲染内核换血:Filament驱动下的PLY点云与3DGS实践 我自己就是做移动端3D渲染的折腾Sceneform有些年头了。Google在2020年停了Sceneform的维护之后社区里其实还活着很大一批人因为市面上实在找不到一个能同时在ARCore、低端安卓机、3D数据可视化这几个场景里无缝切换的替代品。Sceneform-EQR这个分支就是社区里一直在做维护续命的一个方向而我这篇文章想聊的是我们基于EQR做的一次比较大的内核换血——把渲染底层从Sceneform原生的OpenGL ES管线切到Filament然后重新打通PLY点云、Mesh加载顺手探索了一把3D Gaussian Splatting在移动端的落地可能性。这篇文章没有任何PPT式的框架全是工程记录和踩坑笔记。如果你正在做点云可视化、三维标注工具、或者想让安卓设备直接渲染高密度点云和mesh模型那这段实操记录应该能帮你省掉不少查文档和爬坑的时间。我知道很多人看到点云Gaussian Splatting会下意识觉得这是自动驾驶和图形学实验室的专利但你往下看会发现这套东西放到手机上没那么神秘。1. 项目缘起为什么2026年了还要回头动Sceneform1.1 现实困境没有替代品的Sceneform生态先说一个很实际的问题Sceneform停更之后如果你的业务还没上车要不要继续用它我的答案是要看你的核心诉求是什么。Sceneform最独特的地方在于它跟ARCore的深度绑定。你不需要写任何OpenGL代码就能在一个Java/OpenGL ES的桥接层里把相机画面、虚拟物体的遮挡、光照、阴影全部处理好。这在做AR测量、AR预览、虚拟摆件这类场景时极其高效。但它的坑也很明显底层硬编码了OpenGL ES的实现模型格式只原生支持OBJ和glTF渲染性能在点云这种动辄几十万顶点的数据面前不堪重负。我们接手的一个项目是移动端的3D地形勘测工具需要在手机上看采集回来的激光点云叠加mesh模型还要支持简单的标注和测量。一开始选的方案就是Sceneform因为开发快、社区熟。结果数据一上量就发现OBJ格式、OpenGL管线、CPU侧逐顶点上传这三座大山让帧率直接崩到个位数。这时候你有两条路换引擎比如Godot或Unity但代价是ARCore的接入和原有业务代码全部重写要么就是基于社区分支去换渲染内核。我们选了后者。原因很简单ARCore的会话管理和坐标追踪逻辑是整个应用的骨架我不想动它。而渲染层既然是OpenGL ES的死胡同那就把这一层换掉。Sceneform-EQR这个名字里的EQR是社区分支一直在做的扩展功能集合我们在这个基础上进一步替换了Filament。1.2 渲染内核换血为什么选择FilamentFilament是Google开源的跨平台实时渲染引擎最值得夸的就是它的PBR基于物理的渲染模型和跨GPU驱动层的抽象能力。它在移动端做了深度的性能调优支持Metal、Vulkan、OpenGL ES 3.x而且代码是C写的对Android开发者来说有一层Java/Kotlin的JNI封装可以直接调用。选Filament而不是自己写一套简单的点云渲染器有个根本原因我必须同时支持Mesh 点云 PBR材质 相机交互。如果只用GLES自己手搓这些东西全做下来几个月就过去了。Filament把基础的渲染管线和材质系统都搭好了我只需要把数据格式转成它认识的模型和自定义顶点流就能搭起一个完整可用的渲染器。另一个很重要的点是Filament的异步渲染。它能把渲染命令的提交放到自己的线程池里这样我可以把点云数据上传、mesh加载这些CPU开销极大的操作丢到后台不阻塞主线程。1.3 项目目标PLY点云、Mesh、3DGS三合一标题里写了三个东西PLY点云、Mesh、3D Gaussian Splatting。我们的目标拆开来是这样的原生支持PLY格式的点云文件不仅限于简单的XYZRGB还要支持法向量、透明度、多顶点域。Mesh方面除了Sceneform原有的OBJ和glTF能力要补上PLY格式的三角面片支持。因为很多三维重建输出的mesh就是PLY格式比如Open3D、MeshLab、COLMAP导出的结果。3D Gaussian Splatting是实验性的方向。我不指望移动端能跑全量训练但至少要做到能加载一个提前训练好的Splat模型并且在实时渲染中查看新视角。从工程上讲这个目标倒推回去核心问题只有三个渲染器用什么、数据格式怎么定义、坐标系统怎么统一。而这三个问题的答案最后都落在了Filament上。2. 架构设计Filament接入与Sceneform的兼容层2.1 Filament在Android端的集成姿势Filament在Android端有两种用法一种是你自己创建一个SurfaceView或TextureView把Filament的Engine、Renderer、View从零开始搭起来完全脱离Sceneform。另一种是我们采用的方案——做兼容层让Filament作为一个独立的渲染后端仍然由Sceneform的Scene和Camera来管理虚拟世界坐标和ARCore相机位姿但最终提交渲染命令时走Filament而不是Sceneform自身的OpenGL管线。具体集成过程是// 初始化Filament引擎 val engine Engine.create(Engine.SHADER_MODEL_MOBILE) // 创建Renderer val renderer engine.createRenderer() // 创建View这里可以理解为渲染窗口 val view engine.createView() view.scene sceneFilament view.camera cameraFilament view.viewport Viewport(left, top, width, height)这里有个关键点Filament的Scene和Camera并不直接跟ARCore的坐标绑定需要我们每帧去同步。ARCore的相机位姿会给我们一个Pose矩阵转成Filament的Camera矩阵然后手动设置给Filament相机。大概是这样一个流程override fun onDrawFrame(glFrame: Frame?) { // 获取ARCore相机位姿 val pose frame.camera.pose // 把Pose转成Filament需要的视图矩阵 val viewMatrix pose.toFilamentViewMatrix() // 更新Filament相机 cameraFilament.setModelMatrix(Matrix4.lookAt(...).toFloatArray()) // 提交渲染 renderer.render(view) }这一步是我觉得最值得讲清楚的。Sceneform的渲染循环是它内部的GLSurfaceView.Renderer来驱动的当我们要替换成Filament时实际上是把Filament挂到同一个GLSurfaceView的渲染回调里。这样ARCore的会话获取相机帧、光照估计这些都不受影响只是最终画面输出从原生的GLES管线换成Filament管了。2.2 实体模型对齐Scene里的Node怎么映射到FilamentSceneform的场景树是Node每个Node可以挂Renderable。Renderable里面有Mesh、Material、Animation等信息。Filament这边是RenderableManagerEntityMaterialInstance思路几乎一样。所以我们做了一个映射把Node作为逻辑节点保留Renderable拆成Mesh和Material两部分分别对应Filament的VertexBuffer、IndexBuffer和MaterialInstance。这样一来原有业务代码里对Node的transform操作、父子级联、碰撞检测全部不用改。值得一提的是Filament的实体系统Entity也是用ECS架构的你创建一个Entity是为了给它的组件赋值比如TransformManager、RenderableManager。这种设计在数据密集场景下性能远好于传统的游戏引擎GameObject体系因为它缓存友好、无虚函数调用。举个例子加载一个mesh的时候// 创建Entity val entity EntityManager.get().create() sceneFilament.addEntity(entity) // 创建VertexBuffer描述顶点布局 val vertexBuffer VertexBuffer.Builder() .vertexCount(mesh.vertexCount) .bufferType(VertexBuffer.VertexAttribute.POSITION) .bufferType(VertexBuffer.VertexAttribute.COLOR) .bufferType(VertexBuffer.VertexAttribute.UV0) .build(engine) vertexBuffer.setBufferAt(engine, 0, FloatBuffer.wrap(positionData)) vertexBuffer.setBufferAt(engine, 1, FloatBuffer.wrap(colorData)) vertexBuffer.setBufferAt(engine, 2, FloatBuffer.wrap(uvData)) // 创建Renderable val renderable RenderableManager.Builder(0) .boundingBox(Box(center, halfExtent)) .material(0, materialInstance) .geometry(0, RenderableManager.PrimitiveType.TRIANGLES, vertexBuffer, indexBuffer) .build(engine, entity) // 设置transform val ti engine.transformManager.getInstance(entity) engine.transformManager.setTransform(ti, Matrix4(...).toFloatArray())这样写的好处是数据流很清晰上游是PLY解析器吐出来的就是Position、Color、UV、Index四个数组下游是Filament的VertexBuffer/IndexBuffer只需要把数组塞进去。中间不需要去转成OBJ或者glTF省掉一个序列化步骤。2.3 Filament渲染的PBR特性和我们能用上的部分Filament在移动端最吸引人的其实不是性能而是它的PBR材质系统。它推出了一个超简化的材质描述语言Filament Material你可以用类似JSON的格式定义材质material { name : PbrPointCloud, parameters : [ { type : float3, name : colorBase }, { type : float, name : pointSize } ], shadingModel : unlit, // 点云不需要光照 vertexDomain : object, culling : none } fragment { void material(inout MaterialInputs material) { prepareMaterial(material); material.baseColor.rgb getColor().rgb; material.baseColor.a 1.0; } } vertex { void materialVertex(inout MaterialVertexInputs material) { // 可以在顶点阶段计算点的大小 } }这个材质系统对点云渲染特别友好因为它可以让你写unlit不受光照的材质同时还能走完整的PBR管线不需要光影计算。而且它支持vertexDomain: object这个选项意味着顶点的坐标是在物体局部坐标系里而不是世界坐标这对点云这种大数据量的模型来说能省掉一帧内的坐标转换。不过要提醒一句Filament的点元渲染POINTS在移动端不完全受支持有些GPU驱动会莫名其妙把点渲染成小方块。所以我在点云渲染时是走一个通用方案把每个点扩展成一个小的quad四边形用几何着色器或顶点着色器做扩边。这个是我们的核心优化之一后面会详细讲。3. PLY文件解析与点云/Mesh数据管线的设计3.1 PLY格式要点不只是读点这么简单PLYPolygon File Format是Stanford开发的一种多边形模型文件格式也是三维扫描、点云处理领域的事实标准。它的结构分两部分头部header和数据体。头部用纯文本描述顶点数、面数、属性类型数据体可以是ASCII也可以是二进制。很多人在解析PLY时只关心顶点坐标和颜色这只够渲染静态点云。但实际工程里我们会遇到更复杂的PLY文件顶点的属性顺序可能不一样有的文件先写x y z then nx ny nz then red green blue有的是x y z red green blue还有的自定义属性比如intensity、confidence。数据体可能是二进制有little endian和big endian两种。face元素里可能有3个顶点三角形也可能有多边形4个及以上顶点需要自己做三角化。我最后是写了一个通用的PLY解析器花了相当多的时间。核心逻辑是# 简化的PLY头解析逻辑 def parse_ply_header(file): header {} file.seek(0) line file.readline().strip().decode() while line ! end_header: tokens line.split() if tokens[0] element: # 记录元素类型vertex/face等和数量 header[elements].append({ name: tokens[1], count: int(tokens[2]), properties: [] }) elif tokens[0] property: # 记录属性类型和名称 current_element header[elements][-1] current_element[properties].append({ type: tokens[1], name: tokens[2] }) line file.readline().strip().decode() return header这个解析器要能动态识别属性顺序。我的处理方式是先把所有属性名映射到固定槽位比如POSITION对应x y zCOLOR对应red green blueNORMAL对应nx ny nz然后不管源文件属性的排列顺序是什么都能正确取出并重排成我们渲染需要的数据布局。3.2 点云与Mesh在渲染管线中的区别PLY文件里的点云和Mesh本质上都是顶点数组但渲染路径完全不同点云只有顶点缓冲没有索引缓冲。渲染方式是POINTS点精灵或者像我上面说的把点扩展成小的四边形。点云没有拓扑关系单个顶点就是一个绘制单元。Mesh顶点索引索引定义了顶点之间的连线方式三角形、四边形、多边形。Mesh能进行光照计算、背面剔除、阴影投射等。在Filament中点云用PrimitiveType.POINTS就可以但如果遇到移动端GPU不支持点精灵的情况就必须用PrimitiveType.TRIANGLES也就是生成扩展quad。我们自己做了一个扩展quad的方案思路非常直接// 顶点着色器里把点坐标扩展到四边形 vec4 clipPos (MVP * vec4(worldPosition, 1.0)); float halfSize uniformData.pointSize * clipPos.w; vec2 offsets vec2((gl_VertexID 1) 0 ? -halfSize : halfSize, (gl_VertexID 2) 0 ? -halfSize : halfSize); gl_Position clipPos vec4(offsets, 0.0, 0.0);前提是你在上传VertexBuffer时把同一个点复制成了4份然后用索引buffer按四边形组合。这样虽然内存占用多了4倍但对于卡片级的点渲染来说是完全可控的。而且这样做的兼容性极好再老的GPU也不会把点渲染得乱七八糟。3.3 Mesh渲染与场景管理Mesh这边相对轻松。Filament的RenderableManager支持TRIANGLES、TRIANGLES_STRIP、LINES等图元类型。PLY中带面的模型解析时把face的顶点索引读出来存到IndexBuffer然后直接作为Filament的Geometry数据。这里有个坑是PLY的face索引是四边形或更高多边形的时候你没法直接塞给GPU。需要做三角剖分。对凸多边形最通用的做法是扇形剖分fan triangulation取第一个顶点然后跟后续每对相邻顶点组成三角形。我在做地形mesh的时候遇到过大量四边形face用这个方法没有任何问题因为地形网格的三角形质量本来就很高。整体场景管理上我也做了一层封装。如果同一个PLY文件里同时含有点和面比如一个带有激光雷达强度值的彩色mesh那么我会拆成两个Renderable一个渲染点一个渲染面这样调整点的大小和面的透明度就方便很多。4. 3D Gaussian Splatting移动端的降级实现4.1 3DGS基本原理和移动端瓶颈3D Gaussian Splatting3DGS是这两年3D视觉领域火得最离谱的方向之一。核心思想是用一堆3D高斯分布每个分布有位置、协方差、颜色、不透明度来表示一个场景渲染时把这些高斯分布按视角排序从近到远合成像素。跟传统的NeRF神经辐射场相比3DGS是完全显式的可以实时渲染。跟传统Mesh比它不需要拓扑结构可以表示非常复杂的非表面场景比如烟雾、植被、透明物体。这也是为什么自动驾驶、机器人、游戏开发都在疯狂研究它。但移动端的瓶颈也很明显训练3DGS模型极其耗显存和算力通常需要用高端GPU训练好几个小时。渲染3DGS时需要对高斯进行排序排序的数据量可能在百万级别移动CPU扛不住。所以我们的探索路径是不做在线训练只做静态模型加载渲染而且只支持简化版的高斯渲染。4.2 训练端如何在桌面端先把场景训出来为了在移动端渲染3DGS首先需要有一个训练好的Splat模型。标准的3DGS训练流程是输入一组某个场景的多视角照片通过Structure-from-Motion比如COLMAP恢复相机位姿和稀疏点云然后再训练3DGS模型。COLMAP输出的稀疏点云正好是PLY格式。我之前在项目里已经做了PLY解析所以这一步的数据流转非常顺COLMAP导出的稀疏点云每个点带有位置、颜色和协方差信息我们可以直接读入作为3DGS的初始高斯参数。如果你没有COLMAP输出也可以直接用AI生成或者摄像头采集的视频做重建。GitHub上开源了一个叫gaussian-splatting的项目官方支持COLMAP路径。我自己用下来如果输入是20~30张照片大概半小时能训出一个质量还不错的小场景。训练完成后官方项目会导出几个文件其中有一个是带ply扩展名的模型文件里面存了每个高斯的中心位置、四元数旋转、缩放、SH系数球谐函数系数和透明度。这就是我们移动端需要加载的文件。4.3 移动端渲染降低Gaussian数量的策略移动端加载这个大PLY文件后不能直接全量渲染。一个训练好的3DGS场景通常有50万到200万个高斯。如果你按原始的3DGS渲染算法逐个排序并计算径向基函数那在手机上是天方夜谭。我们采用的降级策略是「K级抽稀 固定视角排序」。具体做法预处理时用Farthest Point Sampling最远点采样把高斯数量从百万级降到5万~10万。这个采样算法很直观先随机选一个点然后每次选一个离已选点集最远的点加入保证采样的点能均匀覆盖整个空间。渲染时固定排序顺序。正常情况下每个新视角都要重新对所有高斯排序这是性能瓶颈。我们的降级做法是把视角划分成若干离散的方向区间每个区间提前算好排序结果切换视角时直接按最接近的区间去查表。视觉效果会有一定的跳变但在手机上看实时的preview已经够用。用Filament的Position和Color属性直接渲染高斯的中心点颜色的预计算结果来自SH系数的简化版只用DC分量也就是平均色。换句话说这并不是完整意义的3DGS渲染而是把3DGS降级成了一个带颜色和不透明度的粒子系统。这套东西量级降下来之后渲染开销跟一个中规模粒子系统差不多了。Filament每一帧能推2万个粒子是毫无压力的不碰上100M以上粒子的极端场景基本不会卡。5. 数据采集与预处理实战RealSense D435到点云标注5.1 RealSense D435点云获取聊到点云绕不开的一个话题是怎么获取点云。我自己在项目里用得最多的是Intel RealSense D435你们在标题的热搜词里也看到了这个关键词。D435是一款基于主动红外立体视觉的深度相机分辨率1280x720工作距离在0.2米到3米之间非常适合室内和近距离扫描。RealSense提供了librealsenseSDK可以直接获取对齐后的彩色图像和深度图像然后通过SDK内置的pointcloud模块生成点云。核心代码逻辑如下import pyrealsense2 as rs # 初始化pipeline pipeline rs.pipeline() config rs.config() config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) pipeline.start(config) # 对齐 align rs.align(rs.stream.color) frames pipeline.wait_for_frames() aligned_frames align.process(frames) depth_frame aligned_frames.get_depth_frame() color_frame aligned_frames.get_color_frame() # 点云生成 pc rs.pointcloud() points pc.calculate(depth_frame) vtx points.get_vertices()D435在采集时有个很关键的参数叫depth_units它决定了深度数据的缩放比例。默认配置下深度像素值除以1000就是米。很多人采集点云后发现坐标特别大或者特别小往往是单位没换算对。另外一个坑是D435的环境干扰光。在阳光直射或户外强光下主动红外会被环境光淹没导致深度孔洞一大堆。我一般建议室内使用或者给相机加遮光罩。5.2 点云拉框标注人机交互的关键实现你们搜索热词里反复出现3D点云标注拉框我在这个项目里正好实现了标注功能。点云拉框和2D拉框不一样的地方在于框必须是三维的AABB包围盒轴对齐包围盒用户需要从视角内确定一个3D框。实现思路用户点击屏幕两个点鼠标或触摸我们通过unproject函数把屏幕坐标转换成三维射线和点云平面求交。第一个点决定了包围盒的一个底面角第二个点决定对角线方向的另一个角高度则通过输入框或滑动条指定。每次修改后把包围盒画出来并高亮包围盒内部的点。坐标转换代码大概是这样的fun screenToWorld(x: Float, y: Float): Vector3? { // 使用Filament的Camera做unproject val ndcX (2.0f * x / width) - 1.0f val ndcY 1.0f - (2.0f * y / height) val viewMatrix filamentCamera.viewMatrix val projMatrix filamentCamera.projectionMatrix // 计算射线 val rayNear unproject(ndcX, ndcY, 0.0f) val rayFar unproject(ndcX, ndcY, 1.0f) // 和点云包围盒的近似平面求交 return intersectWithTargetPlane(rayNear, rayFar) }拉框的数据结构我用的是AABBmin和max两个顶点。存储时把标注结果写成JSON里面包含框的坐标、类别标签、置信度。这个标注结果可以转成KITTI格式或者Waymo格式后续用于训练目标检测模型。5.3 地形点云配准另一个跟点云强相关的事情是配准。我们项目里做地形勘测时可能需要把两次扫描的点云叠加到同一个坐标系。这个过程叫点云配准常用的方法是ICP迭代最近点和NDT正态分布变换。Open3D库对这两个算法都有现成实现实测NDT对地形点云的鲁棒性比ICP更好。因为地形点云往往存在大量的平面结构ICP容易陷入局部最优而NDT先把空间划分为网格然后在每个网格内构建正态分布用分布之间的匹配来迭代收敛更快。如果你要配准的是两份地形点云我建议的流程是先用体素下采样把点云密度统一到一致水平。用FGR或者RANSAC估计初始变换矩阵把两份点云粗对齐。再用NDT精配准把误差降到厘米级。配准完成后两份点云会在同一个坐标参考系下接下来就能在Sceneform-EQR中叠加显示或者做体积测量、剖面分析。6. 性能优化与常见问题排查实录6.1 Filament渲染性能调优的几个关键参数Filament在移动端的默认配置是偏向画质优先的如果直接拿来渲染百万点云帧率会非常难看。必须要调整几个参数MSAA采样数点云这种离散数据不需要抗锯齿把MSAA直接从4x改成1x能省出一大截GPU开销。阴影剔除点云和大多数mesh场景不需要动态阴影关掉Filament的阴影系统尤其是SShadowMap相关的配置。材质采光点云材质用unlit不要用lit这样就不会做漫反射和法向计算。后处理Filament默认会加一个tone mapping和AO环境光遮蔽后处理对点云来说毫无意义全部关掉。我在项目里做了一个测试对比百万点云在骁龙8 Gen1上关闭所有特效后帧率从9帧提升到48帧差距惊人的明显。6.2 常见的崩溃、花屏与渲染异常接下来把这段时间遇到的最典型的几个问题整理成一张排查表供各位参考现象原因解法渲染花屏画面出现大量噪声顶点布局属性不对Color数据格式错误检查VertexBuffer的bufferType是否和PLY属性一一对应确认Color是float3或uint8不能混用点云整体位置漂移ARCore与Filament相机坐标不同步每次onDrawFrame都重新设置Filament相机矩阵不能只设置一次Mesh加载后没有光照未给Mesh材质设置shadingModel为lit在Filament material中把shadingModel改成lit并确保有normal属性PLY文件加载失败文件头解析错误属性顺序与你预设的顺序不同强化解析器使用关键字索引属性位置而非顺序读取模型显示但有锯齿线框或顶点不连续设置PrimitiveType为TRIANGLES或LINES不要用POINTS渲染封闭mesh加载巨量点云时内存飙升顶点数据没有复用全量buffered对点云做下采样例如把32位float压缩为16位半精度float色彩用uint8打包6.3 点云数据预处理中的坑最后讲讲点云数据预处理中很容易被忽视的细节。一是单位统一。不同来源的点云单位可能是米、毫米、厘米甚至英尺。你从D435拿到的是米从COLMAP拿到的是米但有些离线数据集是毫米。如果混用必炸。我一般约定所有数据在进入渲染器之前统一换算成米并且只在预处理阶段换算一次运行时不做。二是坐标系朝向。PLY文件中点云的坐标系可能是右手系也可能是左手系取决于采集软件。如果渲染出来镜像了或者前后颠倒就需要手动翻转某个轴。一个简单的判断方法渲染出来看贴地平面方向能不能对上真实场景。三是颜色空间。D435输出的RGB是sRGB颜色空间而Filament的PBR管线默认是物理线性空间。如果不做转换画面会显得过暗或者颜色偏灰。正确做法是上传到顶点缓冲之前把RGB转成线性空间用pow(color, 2.2)或者查表都能做。最后分享一个实际工作中的小技巧我在调试Filament渲染的时候发现一个特别实用的习惯把Filament的View.debug属性打开它会叠加一层显示渲染统计信息的面板比如绘制调用次数、三角形数量、顶点数量、帧时间。做性能优化时把这个面板打开一边操作一边看数据比盲猜快得多。另外一个习惯是所有PLY处理流程都先用离线脚本做一遍预处理生成一个缓存好的二进制中间格式到了App里只需要做内存拷贝不用解析ASCII。这个中间格式我们叫.eqmEQR Mesh本质上是把PLY顶点数据和索引数组直接按二进制平铺用一个简易头描述布局。实测下来同样的模型从PLY解析到渲染加载时间从约700ms直接降到不到80ms。这两点是我在实际项目里觉得回报率最高的两个优化。如果你也被点云渲染和Filament折腾得头疼可以先从这两步开始做。这个项目后续我还会继续深入的方向是把3DGS的渲染做得更完整在支持Vulkan的设备上开启compute shader做真正的逐高斯排序以及让PLY解析支持更多属性域比如热力图数值和分类标签。如果你们有相关的需求或者更好的思路欢迎一起交流。
返回列表