关键词: Android 3D、Sceneform-EQR、Filament、Mesh、Vertex、GLB、PLY、ModelRenderable

摘要: 本文以 Sceneform-EQR 的可运行示例为主线,从基本图元开始,依次讲清 Vertex、法线、UV、索引、
RenderableDefinition、GeometryUtils、Node与SceneLayout的关系,并完成 Triangle、Plane、Cube、GLB 和 PLY 的加载。文章不仅给出代码,还会解释每一步为什么这样写、运行时应该观察什么,以及 Fragment 退出后怎样处理异步回调和渲染资源。
一、本篇导读
1.1 关于本专栏
本专栏以Sceneform-EQR仓库中的全部 Lesson Fragment 为主线,系列从 Vertex、Primitive 和 Mesh 起步,逐步进入 Android View 三维渲染、PBR 材质与光照、动画、射线交互、视频外部纹理、AR/VR,以及资源与性能工程化。
1.2 关于 Sceneform-EQR 仓库
Sceneform-EQR 是一个由 Google Sceneform 延伸而来的 Android 原生 3D/XR 渲染库,底层使用 Google Filament。它在 Sceneform 场景树与 Android 生命周期模型之上,继续扩展了 GLTF/GLB、PLY 点云与 Mesh、基础及动态几何、动画、射线交互、Android View 三维渲染、视频外部纹理、ARCore、华为 AREngine、VR、场景截图等能力。
仓库的三个主要工程边界如下:
| 目录 | 定位 | 本系列中的作用 |
|---|---|---|
Eq-Renderer/Android/eq-renderer | Android 渲染库与 Native Filament 集成 | 提供 SceneLayout、Node、Renderable、GeometryUtils、加载器及公开 API |
SampleProj | 可运行的 Android 示例与教程应用 | 提供每篇文章对应的 Fragment、场景、资源与交互入口 |
Tool | Filament 离线工具和材质/环境资源 | 编译 .mat、生成 .filamat、IBL 与其他渲染资产 |
当前 main 分支采用 Apache-2.0 许可证,支持 ARCore 与 AREngine,但不包含 GPL 许可的 ORB-SLAM3 实现;相关集成只在 main-GPLv3 分支说明。使用、二次开发或发布前,应区分主分支与 GPLv3 路线,不能把“存在集成文档”描述成“主分支已内置全部 SLAM 能力”。
仓库入口与使用方式:
- GitHub:eqgis/Sceneform-EQR
- 中文说明:README_CN.md
- 教程源码:tutorial 包
下载地址:
https://github.com/eqgis/Sceneform-EQR/releases#release-v1.2.1

1.3 本篇定位:为什么从基础几何开始
第一次接触 Android 3D 渲染时,我们很容易把注意力全部放在“模型为什么没有显示”上,却忽略了 GPU 真正接收的并不是 Cube、汽车或者建筑,而是一组顶点、一种图元拓扑、一份材质以及一次节点变换。
这也是基础几何必须放在系列第一篇的原因。如果不理解点、线、三角形和索引,后面即使成功加载了 GLB,也很难解释模型为什么发黑、为什么被裁剪、为什么旋转后消失,或者 PLY 为什么有时表现为点云、有时又表现为 Mesh。
本文不会把几个 Fragment 写成互不相关的 API 清单,而是沿着一条完整的数据链展开:
导读图: 自定义几何对象和 GLB/PLY 资产最终都会进入 Renderable—Node—Scene 这条公共渲染链路,区别主要发生在数据生产与解析阶段。

我们先让 GPU 画点、线和三角形,再自己组织三角形得到平面与 Cube,最后把几何数据的生产工作交给 GLB、PLY 加载器。读完后,你应该能够回答三个问题:
- 屏幕上的形状从哪里来?
- Renderable 为什么会出现在相机前方的这个位置?
- 模型加载完成或页面退出后,节点和资源由谁管理?
1.4 这篇文章能解决什么
- 理解
POINTS、LINES、LINE_STRIP、TRIANGLES、TRIANGLE_STRIP的差异; - 手动创建三个 Vertex 和一个三角面,跑通最小 Mesh 管线;
- 使用
GeometryUtils创建 Plane 与 Cube,并区分几何中心和 Node 变换; - 使用
ModelRenderable.builder()加载 GLB 与 PLY; - 理解
ScaleTool、屏幕中心射线、异步有效性检查和节点释放; - 知道什么时候应该选择 GLB,什么时候 PLY 更合适。
1.5 学习路线与源码地图
| 阶段 | 要解决的问题 | 对应源码 |
|---|---|---|
| 基本图元 | 顶点怎样被解释成点、线和面 | PrimitiveLessonFragment.java |
| 第一个 Mesh | Vertex、索引和材质如何组成可渲染对象 | TriangleLessonFragment.java |
| 参数化几何 | 怎样快速生成平面与立方体 | PlaneLessonFragment.java、CubeLessonFragment.java |
| 模型加载 | GLB 场景资产和 PLY 几何数据怎样进入渲染管线 | GltfLessonFragment.java、PlyLessonFragment.java |
| 场景复用 | 多个示例怎样共享同一个 SceneLayout | GeometryTopicFragment.java |
1.6 运行基线
| 项目 | 当前基线 |
|---|---|
| Sceneform-EQR | 1.2.1 |
| Filament | 1.75.0 |
| minSdk | 24 |
| compileSdk / targetSdk | 34 / 34 |
| Java / JDK | Java 8 Target / JDK 17 |
| 示例 ABI | arm64-v8a |
| 模型资源 | assets/gltf/bee.glb、assets/ply/sofa.ply |
本文以 Android 真机效果为最终判断依据。assembleDebug 成功只能证明 Java、资源与依赖关系正确,不能替代 GPU 驱动、模型尺度、透明混合和 Native 内存的设备验证。
1.7 完整源码与阅读说明
为了让代码块保持可读,正文省略了 import、按钮面板和异常提示等非核心代码。文中的接口名、资源路径和生命周期判断均来自当前仓库;需要复制运行时,请以完整源码为准:
参考: 本文对 Vertex Attribute、Primitive、Rasterization、Material 与 Camera 的描述遵循 Filament Rendering Engine 和 Khronos 图形 API 的通用管线语义;glTF/GLB 以 glTF 2.0 Specification 为准;PLY 元素与属性结构参见 Stanford 的 The PLY Polygon File Format;3D Gaussian Splatting 的表示和实时渲染方法参见 INRIA 原论文项目 3D Gaussian Splatting for Real-Time Radiance Field Rendering。
建议先完整阅读第二部分,再按第三部分的顺序在真机运行。直接跳到 GLB/PLY 虽然也能看到模型,但会错过 Vertex、拓扑和 Node Transform 这条最重要的基础链路。
二、图形学基础与公共渲染原理
2.1 顶点不等于屏幕上的一个点
一个顶点通常不只有位置。为了让材质、纹理和光照正确工作,它还可能携带法线、纹理坐标、顶点色等属性。
| 顶点数据 | 解决的问题 | 缺失或错误时的表现 |
|---|---|---|
position | 顶点在局部空间中的位置 | 几何变形、尺寸异常或完全不可见 |
normal | 表面朝向和光照输入 | 高光方向错误、表面发黑 |
uvCoordinate | 二维纹理怎样映射到表面 | 纹理拉伸、翻转或采样位置错误 |
| color / tangent | 顶点颜色、法线贴图方向等扩展数据 | 材质无法表达预期细节 |
| index | 顶点复用和图元连接顺序 | 面连接错误、绕序反转 |
顶点位置最终要经过模型、观察和投影变换:
p_clip = Projection × View × Model × p_local
p_local是 Vertex 中记录的局部坐标;Model来自 Node 的位置、旋转和缩放;View来自 Camera 的观察姿态;Projection由相机 FOV、宽高比和 Near/Far 裁剪面决定。
因此,“修改 Vertex 坐标”和“移动 Node”虽然都能改变屏幕结果,但职责完全不同。前者修改几何数据,后者修改同一份几何实例在场景中的姿态。

Vertex 提供局部几何数据,Node 提供场景变换,Camera 完成观察与投影。
2.2 同一组顶点为什么能画出不同结果
关键在于 Primitive Type,也就是 GPU 怎样解释顶点或索引序列。
| Primitive Type | 连接规则 | 典型用途 |
|---|---|---|
POINTS | 每个索引绘制一个独立点 | 点云、关键点、调试顶点 |
LINES | 每两个索引组成一条独立线段 | 边框、坐标轴、测量线 |
LINE_STRIP | 相邻索引首尾连接 | 轨迹、曲线、连续折线 |
TRIANGLES | 每三个索引组成一个独立三角形 | 通用 Mesh |
TRIANGLE_STRIP | 从第三个索引开始,每增加一个索引形成新三角形 | 带状面、规则连续表面 |
例如,同样是四个顶点:
- 使用
POINTS时,屏幕上看到四个独立点; - 使用
LINE_STRIP时,得到一条由三段组成的折线; - 使用三角形索引
0, 1, 2, 0, 2, 3时,得到由两个三角形组成的 Quad。
索引的价值不仅是“决定连接顺序”,还可以复用顶点。一个 Quad 若完全展开需要六份顶点数据,使用索引后只保留四个 Vertex 和六个 Index。模型越复杂,顶点复用对内存和缓存命中的影响越明显。

同一类坐标数据采用不同 Primitive Type 后,GPU 得到的连接关系完全不同。
2.3 坐标系、法线与绕序
三个顶点的排列顺序决定三角形的正反面。假设三个顶点为 v0、v1、v2,几何法线可以由叉乘得到:
normal = normalize((v1 - v0) × (v2 - v0))
当顶点绕序反转时,叉乘结果也会反向。如果材质开启背面剔除,最直观的现象就是:从正面能看见三角形,旋转到背面后完全消失。若绕序正确但 Normal 错误,几何仍然存在,光照结果却可能发黑或高光方向异常。
本文示例把物体放到相机前方的负 Z 方向。调试位置问题时,应明确当前数值属于哪一层:

Vertex 记录模型局部空间中的几何数据,Node Transform 将其变换到世界空间,Camera 再完成观察、投影与屏幕映射。
这一区分会贯穿后续动画、拾取和 AR Anchor:动画通常修改 Node Transform,射线命中返回世界坐标,而 Vertex Buffer 始终描述几何本身。
2.4 从数据到屏幕的公共实现原理
一份几何出现在 Android 屏幕上,大致经过下面这条链路:

从工程结构看,这条链路又分成三层:
| 层级 | 主要对象 | 职责 |
|---|---|---|
| SampleProj (业务) | Fragment、按钮、状态面板 | 组织示例、交互和页面生命周期 |
| Sceneform-EQR (组件) | SceneLayout、RootNode、Node、Renderable | 管理场景树、变换和公开渲染 API |
| Filament (引擎) | VertexBuffer、IndexBuffer、MaterialInstance、Entity | 真正向 GPU 提交几何与材质 |
Java 层并不直接“画像素”。RenderableDefinition 或模型加载器先把数据整理为 Renderable,Node 再把 Renderable 实例放进场景,最终由 SceneLayout 驱动 Filament 完成渲染。
这里还有一个经常被忽略的异步边界:MaterialFactory 和 ModelRenderable.builder() 都可能在页面退出后才回调。因此示例在回调中检查 isSceneActive(),销毁时再解除 Node、Renderable、动画和手势引用。这个判断不是样板代码,而是防止旧 Fragment 把资源重新挂回已经销毁场景的最后一道门。
2.5 为什么优先使用 GeometryUtils
当前 Sceneform-EQR 已把常用几何创建入口集中到 GeometryUtils。与直接在业务页面里拼装 VertexBuffer 相比,它有两个好处:
- 上层仍然得到统一的
ModelRenderable,可以继续使用 Node、Material、阴影和碰撞系统; - 点、线、平面、Cube 等基础拓扑的 Buffer 装配集中维护,教程代码只关注数据与场景语义。
需要手写任意 Mesh 时再使用 RenderableDefinition;需要频繁更新动态几何时,应复用 Node、Material 和 Renderable,并调用更新接口,而不是每帧重建整棵场景树。
三、Fragment 实战:从基本图元到模型加载
下面按“数据准备 → Renderable 构建 → Node 挂载 → 运行观察 → 生命周期”的顺序进入六个示例。建议不要只看最终截图,而是在 SampleProj 中切换参数,观察图元连接、材质和节点变换怎样共同影响结果。
3.1 基本图元:一次看懂点、线与三角形

PrimitiveLessonFragment在同一个页面切换点、独立线、连续折线、独立三角形和三角带。
PrimitiveLessonFragment 使用 GeometryUtils 构造多种图元,让相同概念在同一页面内对比。当前源码不会用一个通用材质处理所有类型,而是按 Primitive Type 选择点材质、线材质或普通颜色材质。
private ModelRenderable createPrimitiveRenderable(
RenderableManager.PrimitiveType primitiveType,
List<Vector3> positions,
Material material) {
switch (primitiveType) {
case POINTS:
return GeometryUtils.makePoints(positions, material);
case LINES:
return GeometryUtils.makeLines(positions, material);
case LINE_STRIP:
return GeometryUtils.makeLineStrip(positions, material);
case TRIANGLES:
return GeometryUtils.makeTriangles(positions, material);
case TRIANGLE_STRIP:
return GeometryUtils.makeTriangleStrip(positions, material);
default:
throw new IllegalArgumentException(
"Unsupported primitive type: " + primitiveType);
}
}
Renderable 创建完成后,示例会关闭阴影、清理上一个图元 Node,再挂载新节点:
renderable.setShadowCaster(false);
renderable.setShadowReceiver(false);
clearPrimitiveNode();
primitiveNode = new Node();
primitiveNode.setRenderable(renderable);
primitiveNode.setParent(sceneLayout.getRootNode());
这里有三个值得实际操作的观察点:
LINES的坐标必须两两配对,顶点数为奇数时最后一个点无法组成完整线段;LINE_STRIP会复用相邻端点,更适合轨迹和折线;TRIANGLE_STRIP的绕序会交替变化,构造坐标时要让首个三角形先朝向相机。
调试自定义 Mesh 时,可以先把数据转换成点或线去检查位置与连接,再恢复三角面。这个方法往往比一直盯着一块错误表面更高效。
源码地址:PrimitiveLessonFragment.java;公共几何实现见 GeometryUtils.java。
3.2 第一个自定义 Mesh:手动绘制三角形

三个 Vertex、三个索引、一个 SubGeometry 和一份 Material 构成最小可见 Mesh。
TriangleLessonFragment 把流程拆到了最小:三个 Vertex、索引 0, 1, 2、一个 RenderableDefinition.SubGeometry,最后交给 RenderableDefinition 和 ModelRenderable.builder()。
Vertex v0 = Vertex.builder()
.setPosition(new Vector3(-0.8f, -0.45f, -2.4f))
.setNormal(Vector3.back())
.setUvCoordinate(new Vertex.UvCoordinate(0, 0))
.build();
Vertex v1 = Vertex.builder()
.setPosition(new Vector3(0.8f, -0.45f, -2.4f))
.setNormal(Vector3.back())
.setUvCoordinate(new Vertex.UvCoordinate(1, 0))
.build();
Vertex v2 = Vertex.builder()
.setPosition(new Vector3(0, 0.65f, -2.4f))
.setNormal(Vector3.back())
.setUvCoordinate(new Vertex.UvCoordinate(0.5f, 1))
.build();
RenderableDefinition.SubGeometry subGeometry =
RenderableDefinition.SubGeometry.builder()
.setTriangleIndices(Arrays.asList(0, 1, 2))
.setMaterial(material)
.build();
RenderableDefinition definition = RenderableDefinition.builder()
.setVertices(Arrays.asList(v0, v1, v2))
.setSubGeometries(Collections.singletonList(subGeometry))
.build();
ModelRenderable.builder()
.setSource(definition)
.build()
.thenAccept(renderable -> {
if (!isSceneActive()) {
return;
}
triangleNode = new Node();
triangleNode.setRenderable(renderable);
triangleNode.setParent(sceneLayout.getRootNode());
});
SubGeometry 不只是“索引容器”。一个 Renderable 可以包含多个 SubGeometry,每一组索引绑定不同材质,这也是复杂模型能够同时拥有金属、玻璃和塑料区域的基础。
当前仓库优先使用 RenderableDefinition.SubGeometry 与 setSubGeometries()。旧版 Sceneform 中常见的 Submesh、setSubmeshes() 属于兼容接口,不应作为新教程的首选写法。
ModelRenderable.builder() 的回调可能晚于 Fragment 销毁,因此挂载节点前必须再次检查 isSceneActive()。检查应放在真正操作 Node 的回调内部,而不是只在发起任务前判断一次。
如果把索引改成 0, 2, 1,三角形绕序会反转;如果法线仍保持原方向,光照与几何正面也会不一致。这是验证“绕序—法线—背面剔除”关系最直接的实验。
3.3 从三角形扩展到平面与 Cube
Triangle 示例强调 Mesh 的最小构成;Plane 与 Cube 则展示“让几何工具负责生成拓扑,让 Node 负责场景变换”的常见工程写法。
3.3.1 平面:法线、UV 与透明度

透明平面位于相机前方,可用于参考面、标注底板和后续测量场景。
PlaneLessonFragment 使用 GeometryUtils 创建平面,尺寸为 (2, 2, 1),中心位于 (0, -0.35, -2.6)。材质颜色为绿色系,Alpha 为 0.65,用于观察透明平面与背景的叠加关系。
MaterialFactory.makeTransparentWithColor(
requireContext(),
new Color(0.2f, 0.8f, 0.5f, 0.65f))
.thenAccept(material -> {
if (!isSceneActive()) return;
planeNode = new Node();
planeNode.setRenderable(GeometryUtils.makePlane(
new Vector3(2.0f, 2.0f, 1.0f),
new Vector3(0.0f, -0.35f, -2.6f),
material));
planeNode.setParent(sceneLayout.getRootNode());
});
这里的第二个 Vector3 是几何中心,而不是 Node Position。换句话说,Plane 的顶点在创建时就已经位于相机前方;如果之后再移动 planeNode,两层位移还会继续叠加。
透明渲染也不等于简单关闭深度测试。多个透明面相互重叠时,排序、混合模式和写深度策略都会影响结果。这个例子先建立直觉,复杂半透明与图元排序问题将在材质和 3DGS 相关内容中继续展开。
3.3.2 Cube:几何面与节点变换

Cube 几何保持在局部原点,通过 Node 的世界位置和四元数旋转展示多个面。
CubeLessonFragment 创建边长 0.8 的立方体,几何中心保持为 Vector3.zero();节点放到 (0, 0, -2.8),再组合 Y 轴 45° 与 X 轴 30° 的旋转,让读者同时看到多个面。
cubeNode = new Node();
cubeNode.setRenderable(GeometryUtils.makeCube(
new Vector3(0.8f, 0.8f, 0.8f),
Vector3.zero(),
material));
cubeNode.setWorldPosition(new Vector3(0.0f, 0.0f, -2.8f));
cubeNode.setWorldRotation(Quaternion.multiply(
new Quaternion(Vector3.up(), 45.0f),
new Quaternion(Vector3.right(), 30.0f)));
cubeNode.setParent(sceneLayout.getRootNode());
把顶点直接旋转和旋转 Node 都能改变画面,但职责不同:前者修改几何本身,后者修改场景实例。需要让多个实例共享同一份 Renderable,或者后续还要做动画、拾取时,应优先使用 Node 变换。
Cube 还可以帮助检查法线与光照。当前示例加入强度为 80 的 IBL;旋转后如果各个面的明暗关系仍然完全一致,就要检查材质是否真的接收光照,或者法线是否被错误生成。
3.4 模型加载:GLB 与 PLY 是两类不同的数据路线
前面的几何都由 Java 代码直接生成。进入真实项目后,顶点、索引、材质和纹理通常来自外部资产。Sceneform-EQR 最终仍然把它们转换为 ModelRenderable,但 GLB 与 PLY 表达的数据语义并不相同。
3.4.1 GLTF/GLB:带场景语义的资产

GltfLessonFragment复用GltfSampleScene加载 bee.glb,并接入缩放、光照、动画和节点手势。
glTF 可以包含 Mesh、PBR 材质、纹理、节点层级、蒙皮和动画。GLB 是把 JSON 描述与二进制 Buffer 打进单文件容器后的常见形态。
GltfLessonFragment 是教程入口,实际加载逻辑由 GltfSampleScene 承担。它加载 assets/gltf/bee.glb,并明确启用 Filament gltfio 路线:
ModelRenderable.builder()
.setSource(requireContext(), Uri.parse("gltf/bee.glb"))
.setIsFilamentGltf(true)
.build()
.thenAccept(renderable -> {
if (!isSceneActive()) return;
Node modelNode = new Node();
modelNode.setRenderable(renderable);
modelNode.setLocalScale(Vector3.one().scaled(
ScaleTool.calculateUnitsScale(renderable)));
modelNode.setLocalPosition(new Vector3(0, 0, -3.0f));
modelNode.setParent(sceneLayout.getRootNode());
});
独立的 GltfSampleScene 还会等 SceneView 完成测量,再从屏幕中心创建 Ray,把模型放到距离相机 3.6 个世界单位的位置:
int centerX = sceneView.getMeasuredWidth() / 2;
int centerY = sceneView.getMeasuredHeight() / 2;
Ray ray = sceneView.getScene().getCamera()
.screenPointToRay(centerX, centerY);
modelNode.setLocalPosition(ray.getPoint(distance));
这套流程解决了两个常见问题:
- 外部模型的原始单位和包围盒差异很大,先用
ScaleTool.calculateUnitsScale()归一化更容易观察; - 直接写死 X/Y 位置无法保证模型位于当前屏幕中心,Ray 能把二维中心点转换为稳定的三维方向。
模型加载完成后,示例还创建动画、光源并接入 NodeGestureController。销毁时必须先停止动画和延时定位任务,再执行 modelNode.setRenderable(null)、setParent(null)。不能在单个 Fragment 切换时全局销毁共享的 glTF Material/Resource Loader。
源码地址:GltfLessonFragment.java;完整场景见 GltfSampleScene.java。
3.4.2 PLY:以几何属性为中心的数据

PlyLessonFragment复用PlyDataScene,通过同一 ModelRenderable Builder 接入 PLY 数据。
PLY 更关注 Vertex Element、Face Element 及其 Property,常见于三维扫描、点云和网格数据。它可以只包含顶点,也可以同时包含面索引、法线、颜色、UV,甚至 3DGS 使用的自定义属性。
PlyLessonFragment 同样只是教程入口,实际由 PlyDataScene 加载 assets/ply/sofa.ply。与 GLB 最关键的代码差异,是显式指定数据格式:
ModelRenderable.builder()
.setSource(requireContext(), Uri.parse("ply/sofa.ply"))
.setDataFormat(Renderable.RenderableDataFormat.PLY)
.build()
.thenAccept(renderable -> {
if (!isSceneActive()) return;
Node modelNode = new Node();
modelNode.setRenderable(renderable);
modelNode.setLocalScale(Vector3.one().scaled(
ScaleTool.calculateUnitsScale(renderable)));
modelNode.setLocalPosition(new Vector3(0, 0, -3.2f));
modelNode.setParent(sceneLayout.getRootNode());
});
Builder 接口保持一致,并不代表底层加载路径相同。GLB 交给 Filament gltfio;PLY 则进入 Sceneform-EQR 扩展的数据解析与 Native 资源装配流程。常规 PLY 与 3D Gaussian Splatting PLY 也不是同一套渲染方式:
- Point Cloud 可以直接使用点图元;
- 带 Face 的 PLY 可以构建 VertexBuffer 和 IndexBuffer 后按 Mesh 渲染;
- 3DGS 需要 Billboard、透明混合以及随相机变化的深度排序。
所以,“扩展名是 .ply”只能说明数据容器,不能直接说明屏幕上应该怎样画。大文件还要特别关注 Native 内存、解析峰值、缓存目录、ABI 和 Fragment 销毁后的 Asset 释放。
源码地址:PlyLessonFragment.java;完整场景见 PlyDataScene.java。
3.4.3 模型格式选择
| 需求 | 更适合的格式 | 主要原因 |
|---|---|---|
| 完整 PBR 材质、纹理、动画与节点层级 | GLB | 资产语义完整,移动端交付方便 |
| 扫描点、顶点属性、研究数据或自定义 Mesh | PLY | Property 可扩展,便于保留原始几何数据 |
| 面向 Blender 等 DCC 工具和移动端产品展示 | 通常优先 GLB | 工具链成熟,材质和动画支持更完整 |
| 需要保留 Gaussian、SH 等特殊字段 | PLY / 自定义格式 | 可以扩展顶点 Property,但需要自定义渲染路线 |
格式选择不是比较“谁更先进”,而是判断谁能完整表达数据,同时满足移动端包体积、解析耗时、Native 内存和运行时能力。
四、工程实践:场景复用、生命周期与运行验证
高质量的渲染示例不能只证明“第一次能显示”,还要证明切换功能、退出页面、重新进入时不会残留节点和异步任务。仓库中的 GeometryTopicFragment 与各独立 Lesson Fragment 正好展示了两种组织方式。
4.1 一个 SceneLayout 切换多个几何课例
独立 Fragment 适合单功能学习;专题页则没有必要为每次切换都销毁并重建 Filament Engine。GeometryTopicFragment 复用同一个 SceneLayout,使用 lessonNodes 记录当前课例创建的节点:
private void clearLessonNodes() {
for (Node node : lessonNodes) {
node.setParent(null);
}
lessonNodes.clear();
}
private void addNode(Node node) {
if (!isSceneActive()) {
return;
}
node.setParent(sceneLayout.getRootNode());
lessonNodes.add(node);
}
切换 Triangle、Plane、Cube、GLB 或 PLY 之前先调用 clearLessonNodes(),只替换当前场景内容。这种设计有三个直接收益:
- SceneView、Camera 和 IBL 不必反复初始化;
- 当前课例的 Node 有清晰所有者,可以成组卸载;
- 教程切换逻辑与几何创建逻辑解耦,后续新增课例更简单。
但要注意,移除 Node 只能停止场景遍历;模型动画、手势、延时任务和独占 Native Asset 仍要由各自所有者释放。
4.2 Fragment 生命周期中的正确清理顺序
BaseTutorialFragment 在 onViewCreated() 中绑定相机手势,在 onDestroyView() 中先 detach,再交给父类销毁场景。各 Lesson Fragment 则通过 onBeforeDestroyScene() 清理自己的 Node。
推荐顺序如下:

生命周期示意: 页面退出时先关闭持续产生更新的动画、任务和监听器,再解除 Node 与 Renderable,最后销毁 SceneLayout;顺序反转容易留下晚到回调或 Native 资源引用。
不同对象的释放责任并不相同:
| 对象 | 页面退出时的动作 | 原因 |
|---|---|---|
| 普通几何 Node | setParent(null),清空字段引用 | 停止场景遍历和 Fragment 持有 |
| GLB Node | 停动画、取消延时任务、setRenderable(null)、解除父节点 | 断开 RenderableInstance 与动画引用 |
| PLY 数据 | 销毁 RenderableInstance / PLY Native Asset,再解除节点 | 释放 Native 几何与 Buffer |
| 手势控制器 | unSelect() / detach() | 防止单例或 SceneView 继续回调旧节点 |
| 共享 glTF 资源加载器 | 不在单 Fragment 中全局销毁 | 其他场景可能仍在复用 |
4.3 构建与运行
构建完成后,建议按下面的顺序在 Android 真机验证:
- 打开基本图元页,依次切换五种 Primitive Type;
- 打开 Triangle 页,确认正面可见,并尝试反转索引观察背面剔除;
- 打开 Plane 页,从正面和斜侧观察透明混合;
- 打开 Cube 页,确认旋转后能看到多个面和明暗变化;
- 打开 GLB 页,等待 bee.glb 完成缩放、定位和动画初始化;
- 打开 PLY 页,等待 sofa.ply 完成解析和单位缩放;
- 连续进入和退出 GLB、PLY 页面,确认没有残留动画、手势或持续增长的内存。
4.4 结果验收表
| 验收项 | 正确结果 | 异常时优先检查 |
|---|---|---|
| 基本图元切换 | 每种拓扑连接关系明显不同 | Position 数量、Primitive Type、专用材质 |
| Triangle | 蓝色三角形位于相机前方 | 绕序、Normal、Z 坐标、材质回调 |
| Plane | 绿色透明平面与背景正确混合 | Alpha、法线、透明材质、深度关系 |
| Cube | 能同时观察到多个面 | Node Rotation、IBL、相机距离 |
| GLB | 模型尺度合理并能交互/播放动画 | setIsFilamentGltf(true)、ScaleTool、延时定位 |
| PLY | sofa 数据正确显示且尺度合理 | setDataFormat(PLY)、ABI、Native Loader、资源路径 |
五、常见问题与工程排查
5.1 节点存在,但屏幕上什么也看不到
建议按“数据 → Renderable → Node → Camera”的顺序排查:
- Builder 是否成功回调,日志中是否有资产或 Native 加载异常;
node.getRenderable()是否为空,节点是否挂到sceneLayout.getRootNode();- Node 是否 Enabled,缩放是否接近 0;
- 物体是否位于相机前方,Z 方向和 Ray Distance 是否正确;
- Near/Far 裁剪面、材质 Alpha 和三角形绕序是否合理。
不要一开始就修改所有参数。一次验证一层,才能知道问题真正发生在哪里。
5.2 表面发黑、透明异常或从某个方向消失
- 表面发黑:优先检查 Normal、IBL/Light 和材质参数;
- 从背面消失:检查三角形绕序与背面剔除;
- 透明层级错乱:检查透明材质、深度写入和图元排序;
- 纹理方向错误:检查 UV 和模型导出坐标约定。
把金属度、灯光强度或 Alpha 随机调大,只会暂时掩盖问题,不能替代对几何与材质数据的检查。
5.3 GLB 或 PLY 加载后退出页面仍占内存
先判断增长发生在 Java Heap 还是 Native/GPU 内存。确认节点已经从场景树移除,模型动画和手势监听已经解绑,延时任务被取消,异步回调不会再向旧页面挂节点。
GLB 和 PLY 的底层资源不完全相同:GLB 要关注动画与共享 glTF 资源,PLY 要关注 Native Asset 和几何 Buffer。不要在单个 Fragment 中随意调用全局销毁接口,因为共享资源可能仍被其他场景使用。
5.4 为什么编译成功仍不能证明示例正常
编译只能验证 Java、资源和依赖关系。下面这些问题只有运行时才能暴露:
- 模型真实单位导致尺寸过大或过小;
- GPU 驱动对点、线、透明混合的表现差异;
- PLY Native 解析和 ABI 不匹配;
- 视频或动画任务在页面退出后仍持有节点;
- 大模型带来的 Native 内存峰值和首帧卡顿。
因此,构建验证与真机验证必须同时保留,不能互相替代。
六、本篇总结
6.1 从顶点数据到模型资产的完整主线
这一篇建立了 Sceneform-EQR 最重要的渲染主线:Vertex 由图元拓扑组成表面,表面与 Material 形成 Renderable,Renderable 挂到 Node 后进入 Scene;GLB 和 PLY 则把手写几何扩展成真实资产。
真正需要记住的并不是六段 Builder 代码,而是这条数据链,以及每一层的责任边界。下一篇将在这套场景基础上继续讨论 Android View 怎样通过 ViewRenderable 进入三维世界。
| 层级 | 本文对应对象 | 最值得检查的问题 |
|---|---|---|
| 数据 | Vertex、Index、GLB、PLY | 数据是否完整,格式与路径是否正确 |
| 拓扑 | Primitive Type、SubGeometry | 顶点怎样连接,绕序是否正确 |
| 表面 | Normal、UV、Material | 光照、纹理与透明度是否符合预期 |
| 实例 | Renderable、Node Transform | 缩放、位置、旋转和父节点是否正确 |
| 观察 | Camera、FOV、Near/Far | 对象是否位于视锥体与合理深度范围内 |
| 生命周期 | Future、动画、手势、Native Asset | 页面退出后是否仍有回调或资源持有 |
6.2 项目与完整源码
- 项目源码:eqgis/Sceneform-EQR
- 教程包源码:com/eqgis/test/fragments/tutorial
- 基础几何工具:GeometryUtils.java
6.3 参考资料
- PLY / Mesh 加载:【Sceneform-EQR】基于 Filament 支持 PLY 点云 / Mesh 并探索 3D Gaussian Splatting 渲染实现
- Filament 材质文档:Filament Rendering Engine
- glTF 规范:Khronos glTF 2.0 Specification
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/qq_41140324/article/details/163632956




