我自己就是做移动端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年了还要回头动Sceneform
1.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 渲染内核换血:为什么选择Filament
Filament是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格式的点云文件,不仅限于简单的XYZ+RGB,还要支持法向量、透明度、多顶点域。
- 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怎么映射到Filament
Sceneform的场景树是Node,每个Node可以挂Renderable。Renderable里面有Mesh、Material、Animation等信息。Filament这边是RenderableManager+Entity+MaterialInstance,思路几乎一样。
所以我们做了一个映射:把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格式要点:不只是读点这么简单
PLY(Polygon 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 z,COLOR对应red green blue,NORMAL对应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 Splatting(3DGS)是这两年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) }拉框的数据结构我用的是AABB(min和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。这个中间格式我们叫.eqm(EQR Mesh),本质上是把PLY顶点数据和索引数组直接按二进制平铺,用一个简易头描述布局。实测下来,同样的模型从PLY解析到渲染,加载时间从约700ms,直接降到不到80ms。
这两点是我在实际项目里觉得回报率最高的两个优化。如果你也被点云渲染和Filament折腾得头疼,可以先从这两步开始做。
这个项目后续我还会继续深入的方向是:把3DGS的渲染做得更完整,在支持Vulkan的设备上开启compute shader做真正的逐高斯排序,以及让PLY解析支持更多属性域,比如热力图数值和分类标签。如果你们有相关的需求或者更好的思路,欢迎一起交流。