Android 3D 开发教程(三):Sceneform-EQR 配置 PBR 材质、光照、相机与实时阴影

关键词: Android 3D、Sceneform-EQR、Filament、PBR、IBL、Skybox、Camera、Shadow



摘要: 本文以 Sceneform-EQR 的多个可运行 Lesson Fragment 为主线,从金属度---粗糙度 PBR 工作流开始,依次讲清 Directional、Point、Spotlight 三类直接光、IBL 间接光、Skybox 背景、Camera FOV、Near/Far 裁剪面与实时阴影。


文章目录

    • 一、本篇导读
      • [1.1 关于本专栏](#1.1 关于本专栏)
      • [1.2 关于 Sceneform-EQR 仓库](#1.2 关于 Sceneform-EQR 仓库)
      • [1.3 本篇定位:几何之后,画面由谁决定](#1.3 本篇定位:几何之后,画面由谁决定)
      • [1.4 这篇文章能解决什么](#1.4 这篇文章能解决什么)
      • [1.5 学习路线与源码地图](#1.5 学习路线与源码地图)
      • [1.6 运行基线](#1.6 运行基线)
      • [1.7 完整源码与阅读说明](#1.7 完整源码与阅读说明)
    • [二、PBR 材质、光照与相机基础原理](#二、PBR 材质、光照与相机基础原理)
      • [2.1 金属度---粗糙度 PBR 工作流](#2.1 金属度—粗糙度 PBR 工作流)
      • [2.2 直接光、IBL 与 Skybox 的职责](#2.2 直接光、IBL 与 Skybox 的职责)
      • [2.3 相机 FOV 与 Near/Far 裁剪面](#2.3 相机 FOV 与 Near/Far 裁剪面)
      • [2.4 实时阴影是一条完整链路](#2.4 实时阴影是一条完整链路)
      • [2.5 为什么同一组参数不能跨场景照抄](#2.5 为什么同一组参数不能跨场景照抄)
      • [2.6 用标准参照物把主观观感变成可验证结果](#2.6 用标准参照物把主观观感变成可验证结果)
    • [三、Fragment 实战:从 PBR 表面到实时阴影](#三、Fragment 实战:从 PBR 表面到实时阴影)
      • [3.1 PBR 材质参数:同一个球体为什么质感不同](#3.1 PBR 材质参数:同一个球体为什么质感不同)
      • [3.2 直接光类型:Directional、Point 与 Spotlight](#3.2 直接光类型:Directional、Point 与 Spotlight)
      • [3.3 IBL:比较不同粗糙度的环境反射](#3.3 IBL:比较不同粗糙度的环境反射)
      • [3.4 Skybox:区分"环境背景"与"环境照明"](#3.4 Skybox:区分“环境背景”与“环境照明”)
      • [3.5 Camera FOV:用垂直视场角控制透视观感](#3.5 Camera FOV:用垂直视场角控制透视观感)
      • [3.6 Camera Clip:控制可参与渲染的深度范围](#3.6 Camera Clip:控制可参与渲染的深度范围)
      • [3.7 Shadow:串起光源、投射物和接收面](#3.7 Shadow:串起光源、投射物和接收面)
    • 四、工程实践:统一管理场景、资源与生命周期
      • [4.1 用公共基类明确 Node 的所有权](#4.1 用公共基类明确 Node 的所有权)
      • [4.2 异步材质回调为什么必须检查页面状态](#4.2 异步材质回调为什么必须检查页面状态)
      • [4.3 Fragment 与 SceneLayout 的生命周期顺序](#4.3 Fragment 与 SceneLayout 的生命周期顺序)
      • [4.4 KTX 环境资源的加载与配对原则](#4.4 KTX 环境资源的加载与配对原则)
    • 五、常见问题与工程排查
      • [5.1 metallic 设为 1 后,金属为什么反而发黑](#5.1 metallic 设为 1 后,金属为什么反而发黑)
      • [5.2 roughness 越低,为什么噪点、锯齿和环境差异越明显](#5.2 roughness 越低,为什么噪点、锯齿和环境差异越明显)
      • [5.3 Skybox 已经显示,为什么物体仍然像没有环境光](#5.3 Skybox 已经显示,为什么物体仍然像没有环境光)
      • [5.4 切换 Point Light 或 Spotlight 后,物体为什么突然变暗](#5.4 切换 Point Light 或 Spotlight 后,物体为什么突然变暗)
      • [5.5 修改 FOV 后像"相机移动了",这是错误吗](#5.5 修改 FOV 后像“相机移动了”,这是错误吗)
      • [5.6 调整裁剪面后,模型只剩一半是不是模型坏了](#5.6 调整裁剪面后,模型只剩一半是不是模型坏了)
      • [5.7 阴影开关打开了,为什么地面上没有阴影](#5.7 阴影开关打开了,为什么地面上没有阴影)
      • [5.8 快速退出页面后偶发崩溃或残留灯光,应该查哪里](#5.8 快速退出页面后偶发崩溃或残留灯光,应该查哪里)
      • [5.9 编译通过但真机显示不同,如何区分资源问题和设备问题](#5.9 编译通过但真机显示不同,如何区分资源问题和设备问题)
    • 六、本篇总结与延伸阅读
      • [6.1 把画面问题还原成四层职责](#6.1 把画面问题还原成四层职责)
      • [6.2 本篇源码索引](#6.2 本篇源码索引)
      • [6.3 专业术语与参考资料](#6.3 专业术语与参考资料)

一、本篇导读

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 能力"。

仓库入口与使用方式:

下载地址:

https://github.com/eqgis/Sceneform-EQR/releases#release-v1.2.1

1.3 本篇定位:几何之后,画面由谁决定

Android 3D 开发教程(一):Sceneform-EQR 绘制基础几何并加载 GLB/PLY 模型

解决了"顶点和模型怎样进入场景",但几何存在不等于画面可信。同一个球体,既可能像粗糙塑料,也可能像抛光金属;同一个 Cube,在平行光、点光源和聚光灯下会产生不同明暗;相机 FOV 与裁剪面又决定我们能看到多少空间。

本篇把这些变量分为四层:材质描述表面怎样响应光;直接光和 IBL 提供照明信息;Skybox 提供远景背景;Camera 与深度系统决定投影、可见范围和阴影结果。调试时必须分层固定变量,否则一次修改多个参数,即使画面变好也无法知道真正原因。

1.4 这篇文章能解决什么

  • 理解 baseColor、metallic、roughness、reflectance 与 alpha 的职责;
  • 区分 Directional、Point、Spotlight 的方向、位置、衰减和光锥;
  • 使用 KTX IBL 照亮金属表面,并实时调整 IndirectLight 强度;
  • 区分 Skybox 背景与 IBL 环境照明,避免把二者混为一谈;
  • 调整垂直 FOV,理解"移动相机"和"改变投影"的差异;
  • 设置 Near/Far 裁剪面,理解可见范围与深度精度;
  • 配置 Light、ShadowCaster 和 ShadowReceiver,建立完整阴影链;
  • 在 Fragment 退出前解除灯光、Renderable 与 Node 关系。

1.5 学习路线与源码地图

阶段 Lesson Fragment 当前源码中的核心变量
PBR 表面 MaterialPropertiesLessonFragment metallic、roughness、reflectance、alpha
直接光 LightTypesLessonFragment type、intensity、falloff、cone、temperature
环境光 IblLessonFragment KTX IBL、intensity、三种 roughness
远景背景 SkyboxLessonFragment Skybox KTX 与独立 IBL
相机透视 CameraFovLessonFragment vertical FOV 30°~100°
可见范围 CameraClipLessonFragment Near 0.1~3m、Far 4~20m
实时阴影 ShadowLessonFragment Light、Caster、Receiver
公共管理 BaseMaterialCameraLessonFragment Node 挂载、控件与统一释放

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
IBL enviroments/light/lightroom_ibl.ktx
Skybox enviroments/pillars_2k_skybox.ktx

本文源码以本地 main 分支提交 9016b51929cdf58529a3713232d5c5460d4e0c34 为核对基线。enviroments 是仓库真实目录拼写,文章与代码均保持原路径。

1.7 完整源码与阅读说明

正文优先贴出决定行为的完整方法;重复的 TextView、SeekBar 布局代码只在必要处保留。每个 Fragment 都给出文件级 GitHub 链接,截图注释在发布前由真机实拍替换。PBR 术语以 Filament Materials Guide 为准,Filament 的渲染与相机体系参考 Filament Rendering Engine

建议先阅读第二部分,再按第三部分顺序运行。比较参数时一次只改一项:先固定几何和相机,再调整材质;先确认 IBL,再判断金属;最后才处理阴影和艺术效果。

二、PBR 材质、光照与相机基础原理

2.1 金属度---粗糙度 PBR 工作流

PBR(Physically Based Rendering)不是让画面自动"真实"的按钮,而是一套使材质参数遵循一致物理约束的着色方法。当前课例采用金属度---粗糙度工作流:

参数 专业含义 当前课例中的观察方式
baseColor 非金属的漫反射颜色;金属的表面反射颜色 蓝色非金属、金色金属、绿色透明球
metallic 表面按非金属或金属模型响应光照 0 与 1 的对比
roughness 微表面法线分布的粗糙程度 0.16 高光集中,0.75 高光发散
reflectance 非金属垂直入射时的基础反射控制 课例固定为 0.5
alpha 透明度输入 透明材质颜色 Alpha 为 0.48

金属几乎没有普通非金属式的漫反射颜色,主要呈现环境反射;因此没有有效 IBL 时,金属表面很容易发黑。粗糙度不等于亮度:降低 roughness 会让反射更集中、更清晰,却不会凭空增加环境信息。Alpha 只有在材质本身采用透明混合路线时才能形成正确透明效果。

metallic0,1

reflectance0,1

2.2 直接光、IBL 与 Skybox 的职责

直接光来自明确的 Light。Directional Light 主要由方向决定;Point Light 从空间位置向周围发光并发生距离衰减;Spotlight 还增加朝向与内外锥角。三者都可以形成局部明暗和阴影,但无法单独提供完整环境反射。

IBL(Image-Based Lighting)把环境图像预处理为间接光数据,用于漫反射环境照明与镜面反射。Skybox 则是相机看到的远景背景。设置 Skybox 不会自动创建 IndirectLight,设置 IBL 也不会自动让背景可见。

当前 SkyboxLessonFragment 使用 pillars_2k_skybox.ktx 作为背景,却使用 lightroom_ibl.ktx 作为间接光;它们不是同源配套资源。本课例能够说明两个对象职责独立,但生产项目若追求物理一致,应从同一个 HDR 环境生成 Skybox 与 IBL。

2.3 相机 FOV 与 Near/Far 裁剪面

透视相机把视锥体内的三维点映射到二维屏幕。垂直 FOV 决定视锥开口:FOV 变大时视野更广,边缘透视更明显,物体在屏幕上变小;FOV 变小时视野更窄,类似长焦效果。移动相机改变观察位置,修改 FOV 改变投影,两者不能互相等价。

Near 和 Far 决定视锥的近、远边界。小于 Near 或大于 Far 的几何会被裁剪。两者还共同影响深度缓冲的有效精度:Near 过小、Far 过大,会把有限精度分配到过宽范围,更容易出现 Z-Fighting。工程上应让 Near 在业务允许范围内尽量大,让 Far 只覆盖真正需要的距离。

2.4 实时阴影是一条完整链路

实时阴影至少需要三个条件:Light 启用阴影投射,物体 RenderableInstance 启用 ShadowCaster,接收面启用 ShadowReceiver。除此之外,光源方向、Caster 与 Receiver 的空间位置、相机裁剪面和设备阴影精度也会影响结果。

2.5 为什么同一组参数不能跨场景照抄

初学者常把 metallic 1.0、roughness 0.2、IBL 100 或 Light intensity 5000 记录成一组"标准答案",换到其他场景却发现亮度和质感完全不同。原因是渲染参数并不孤立:几何尺度决定光源距离与衰减,材质决定入射能量怎样分配,相机曝光决定最终像素的明暗,环境资源本身又包含不同动态范围。脱离这四项条件谈一个强度值,没有可比性。

以点光源为例,物体距光源的位置发生变化后,照度会随距离快速变化;Falloff 则定义光源影响范围,超出范围后贡献趋近于零。把毫米单位导出的模型当作米制模型加载,光源可能落在几何内部,或者物体整体远超衰减范围。此时盲目把 intensity 乘以十,只是在补偿错误尺度。更合理的流程是先用包围盒或已知尺寸统一单位,确认 Camera、Node、Light 都处于同一空间尺度,再调整光照。

Directional Light 没有普通点光源式的位置衰减,更适合模拟太阳等远距离光源;但它的 intensity 仍会与曝光、IBL 和材质共同决定画面。若 IBL 很强,主光带来的明暗对比会被环境填充削弱;若 IBL 很弱,背光面可能接近全黑。ShadowLessonFragment 将 IBL 降到10、平行光设为2200,是为了突出阴影开关的教学差异,并不代表真实项目必须采用相同配比。

PBR 参数也应在有意义的范围内解释。metallic 通常以0表示非金属、1表示金属,中间值更多用于边界混合、污渍或纹理过渡,不应把所有材料都设成0.5来获得"更亮"效果。roughness 描述微表面分布,不能用负数或依赖远超0~1的数值。reflectance 主要控制非金属的基础反射,真实介电质通常只占一个有限区间;随意拉满会让塑料、陶瓷等表面像覆上一层不自然的强反光。相关参数定义与范围可对照 Filament Materials Guide

透明度还涉及渲染路径而不只是颜色 Alpha。透明材质通常需要混合、排序和深度策略配合;在不透明材质上单独写入0.48的 Alpha,未必得到预期透明效果。当前示例使用 makeTransparentWithColor() 创建透明绿球,目的就是让材质变体与 Alpha 语义一致。真实项目出现透明穿插错误时,要同时检查材质混合模式、几何排序、双面性和深度写入,不能只盯着颜色值。

因此,迁移示例时应迁移"调参方法",而不是复制"最终数值"。先给场景建立中性基线:统一米制尺度、默认60° FOV、合理 Near/Far、一个已知有效的 IBL、一盏方向明确的主光和灰色参照球。确认基线正确后,再将业务模型、纹理和艺术灯光逐项替换。每替换一项保留前后截图,才能把变化归因到唯一变量。

2.6 用标准参照物把主观观感变成可验证结果

"看起来不够金属""光有点怪""透视似乎不自然"都属于主观描述,无法直接定位代码。高效调试需要把感受转换为可观察条件。本文反复使用球体、Cube 与 Plane,正是因为三种几何各自承担不同的诊断职责。

球体的法线方向连续变化,一张画面就覆盖正对视线、掠射角和背光区域,适合观察高光形状、粗糙度和环境反射。低 roughness 金属球应显示相对清晰且集中的环境结构,高 roughness 金属球应显示更宽、更模糊的反射。如果球面出现突变折线,应继续检查网格细分和法线,而不是立即认定 IBL 有问题。

Cube 具有方向明确的平面和棱边,适合检查光线方向与相机透视。Directional Light 下,不同朝向的面应形成稳定明暗分区;Point Light 移动后,受光面会随灯的位置变化;Spotlight 未对准时,Cube 可能落到外锥而变暗。FOV 改变时,Cube 的直线边缘还能放大广角透视差异,使30°和100°的效果更容易比较。

Plane 提供大面积、法线统一的接收表面,适合观察阴影方向、软硬边缘和透明层叠。阴影示例若只有 Cube 而没有 Plane,即使 ShadowCaster 已启用,也缺少清晰的接收参照;若 Plane 太小或位置偏离光线方向,阴影可能生成却落在可见区域之外。把接收面扩大并使用中性灰材质,可以先验证功能链,再恢复业务地面材质。

同一张验收截图应尽量同时包含参照物、操作控件和当前参数标签。只截局部高光会丢失灯光和相机上下文,只截全景又可能看不清材质细节。本文为每个 Fragment 预留一张完整场景图和一张参数对比或局部细节图:前者证明功能入口与整体状态,后者证明参数变化确实产生预期结果。两张图使用相同相机角度时,读者也更容易排除构图变化带来的错觉。

最后,把"正确结果"写成可以判断真假的句子。例如,不写"广角更有冲击力",而写"FOV 从30°增至100°后,三枚 Cube 的世界坐标不变,画面横向可见范围扩大,中心物体屏幕占比下降";不写"阴影正常",而写"Switch 关闭后 Cube 与 Plane 仍显示,只有主光产生的投影消失"。这种验收语言既能用于教程截图,也能直接转化为手工测试用例。

三、Fragment 实战:从 PBR 表面到实时阴影

3.1 PBR 材质参数:同一个球体为什么质感不同

MaterialPropertiesLessonFragment 在相同 IBL、相同球体尺寸和相近位置下放置三个材质球。左侧是 metallic 0、roughness 0.75 的蓝色非金属球;中间是 metallic 1、roughness 0.16 的金色金属球;右侧使用透明材质,颜色 Alpha 0.48、metallic 0、roughness 0.28。

这种并排对比比单独展示一个"好看的材质"更有价值,因为几何和环境保持一致,画面差异可以直接归因于材质参数。

java 复制代码
@Override
protected void onSceneReady(SceneLayout sceneLayout) {
    sceneLayout.addIndirectLight(
            "enviroments/light/lightroom_ibl.ktx", 100);
    createOpaqueSphere(
            new Color(0.1f, 0.48f, 1.0f),
            new Vector3(-1.0f, 0, -3.0f),
            0.0f,
            0.75f);
    createOpaqueSphere(
            new Color(0.92f, 0.68f, 0.22f),
            new Vector3(0, 0, -3.0f),
            1.0f,
            0.16f);
    MaterialFactory.makeTransparentWithColor(
                    requireContext(),
                    new Color(0.22f, 0.82f, 0.64f, 0.48f))
            .thenAccept(material -> {
                if (!isSceneActive()) {
                    return;
                }
                material.setFloat(
                        MaterialFactory.MATERIAL_METALLIC, 0.0f);
                material.setFloat(
                        MaterialFactory.MATERIAL_ROUGHNESS, 0.28f);
                addRenderableNode(
                        GeometryUtils.makeSphere(
                                0.44f, Vector3.zero(), material),
                        new Vector3(1.0f, 0, -3.0f));
            })
            .exceptionally(error -> {
                Log.e(TAG, "创建透明材质失败", error);
                return null;
            });
}

private void createOpaqueSphere(Color color, Vector3 position,
                                float metallic, float roughness) {
    MaterialFactory.makeOpaqueWithColor(requireContext(), color)
            .thenAccept(material -> {
                if (!isSceneActive()) {
                    return;
                }
                applyPbrParameters(material, metallic, roughness);
                addRenderableNode(
                        GeometryUtils.makeSphere(
                                0.44f, Vector3.zero(), material),
                        position);
            })
            .exceptionally(error -> {
                Log.e(TAG, "创建 PBR 材质失败", error);
                return null;
            });
}

private void applyPbrParameters(Material material,
                                float metallic, float roughness) {
    material.setFloat(
            MaterialFactory.MATERIAL_METALLIC, metallic);
    material.setFloat(
            MaterialFactory.MATERIAL_ROUGHNESS, roughness);
    material.setFloat(
            MaterialFactory.MATERIAL_REFLECTANCE, 0.5f);
}

源码地址:MaterialPropertiesLessonFragment.java

MaterialFactory.makeOpaqueWithColor()makeTransparentWithColor() 选择的是不同内置材质路线,因此透明效果不是只给不透明材质设置一个 alpha。异步材质创建完成后,源码先执行 isSceneActive();如果用户已经退出 Fragment,回调不再创建球体和 Node。

调试 PBR 时建议先把 baseColor 设为中性颜色,确认 metallic 取值,再逐步调整 roughness。金属球发黑时先检查 IBL,不要直接把灯光强度无限提高;透明球排序或混合异常时,先确认使用的是透明材质,而不是只检查颜色 Alpha。

3.2 直接光类型:Directional、Point 与 Spotlight

LightTypesLessonFragment 使用同一个 Cube 和接收平面,通过下拉框切换三类 Light。场景 IBL 强度只有12,目的是保留少量环境填充,让直接光差异更加明显。Cube 启用 ShadowCaster,Plane 启用 ShadowReceiver。

三类光源共用色温 5200K 和位置 (0, 1.2, -1.8),但不同类型使用不同参数:

Light.Type 当前强度 方向/位置 额外参数
DIRECTIONAL 1800 朝向 (-0.35,-1,-0.45) 无距离衰减设置
POINT 5000 使用灯光节点位置 Falloff 4.5m
SPOTLIGHT 9000 朝向 (0,-0.25,-3) Falloff 5m,内/外锥 0.22/0.58
java 复制代码
@Override
protected void onSceneReady(SceneLayout sceneLayout) {
    sceneLayout.addIndirectLight(
            "enviroments/light/lightroom_ibl.ktx", 12);
    createReferenceObjects();
    lightNode = new Node();
    addSceneNode(lightNode);
    updateLight(Light.Type.DIRECTIONAL);
}

private void updateLight(Light.Type type) {
    if (!isSceneActive() || lightNode == null) {
        return;
    }
    Light.Builder builder = Light.builder(type)
            .setColorTemperature(5200)
            .setShadowCastingEnabled(true);
    Vector3 position = new Vector3(0, 1.2f, -1.8f);
    Quaternion rotation;
    if (type == Light.Type.DIRECTIONAL) {
        builder.setIntensity(1800);
        rotation = Quaternion.lookRotation(
                new Vector3(-0.35f, -1.0f, -0.45f),
                Vector3.up());
    } else if (type == Light.Type.POINT) {
        builder.setIntensity(5000)
                .setFalloffRadius(4.5f);
        rotation = Quaternion.identity();
    } else {
        builder.setIntensity(9000)
                .setFalloffRadius(5.0f)
                .setInnerConeAngle(0.22f)
                .setOuterConeAngle(0.58f);
        rotation = Quaternion.lookRotation(
                Vector3.subtract(
                        new Vector3(0, -0.25f, -3.0f),
                        position),
                Vector3.up());
    }
    lightNode.setLight(null);
    lightNode.setWorldPosition(position);
    lightNode.setWorldRotation(rotation);
    lightNode.setLight(builder.build());
}

源码地址:LightTypesLessonFragment.java

Directional Light 模拟距离极远的光源,节点旋转决定光线方向,Falloff 对它没有意义。Point Light 从一个空间点向周围照射,需要位置和衰减半径。Spotlight 同时需要位置、朝向与光锥:内锥区域保持主要强度,内外锥之间逐渐衰减,外锥之外不再照亮。

切换类型前先 lightNode.setLight(null),再设置位置、旋转和新 Light,避免旧灯光继续挂在节点。强度数值针对当前场景尺度和曝光,仅用于三类光源对比,不能直接当成所有项目的通用参数。

3.3 IBL:比较不同粗糙度的环境反射

IblLessonFragment 加载 lightroom_ibl.ktx,默认强度80,并放置三个 metallic 1.0 的球体。三者颜色和尺寸一致,只把 roughness 设为0.12、0.48和0.9,从而观察环境反射由清晰到模糊的变化。

java 复制代码
@Override
protected void onSceneReady(SceneLayout sceneLayout) {
    sceneLayout.addIndirectLight(
            "enviroments/light/lightroom_ibl.ktx",
            DEFAULT_IBL_INTENSITY);
    createSphere(new Vector3(-0.9f, 0, -3.0f), 0.12f);
    createSphere(new Vector3(0, 0, -3.0f), 0.48f);
    createSphere(new Vector3(0.9f, 0, -3.0f), 0.9f);
}

private void createSphere(Vector3 position, float roughness) {
    MaterialFactory.makeOpaqueWithColor(
                    requireContext(),
                    new Color(0.82f, 0.86f, 0.92f))
            .thenAccept(material -> {
                if (!isSceneActive()) {
                    return;
                }
                material.setFloat(
                        MaterialFactory.MATERIAL_METALLIC, 1.0f);
                material.setFloat(
                        MaterialFactory.MATERIAL_ROUGHNESS, roughness);
                addRenderableNode(
                        GeometryUtils.makeSphere(
                                0.42f, Vector3.zero(), material),
                        position);
            })
            .exceptionally(error -> {
                Log.e(TAG, "创建 IBL 测试球体失败", error);
                return null;
            });
}

操作区 SeekBar 范围为0~160,但回调使用 Math.max(1, progress),因此实际最低强度为1。滑动时取得场景当前 IndirectLight,只修改 intensity,不重复解析 KTX:

java 复制代码
@Override
public void onProgressChanged(
        SeekBar seekBar, int progress, boolean fromUser) {
    int intensity = Math.max(1, progress);
    label.setText("IBL:" + intensity);
    if (!isSceneActive()) {
        return;
    }
    IndirectLight indirectLight = sceneLayout.getIndirectLight();
    if (indirectLight != null) {
        indirectLight.setIntensity(intensity);
    }
}

源码地址:IblLessonFragment.java

观察 IBL 时不能只看整体亮度。低 roughness 球应出现更集中、轮廓更清晰的环境反射;高 roughness 球的反射被卷积得更宽、更柔。如果三者只同步变亮却没有反射形态差异,应检查材质是否真正使用 metallic 与 roughness 参数。

3.4 Skybox:区分"环境背景"与"环境照明"

SkyboxLessonFragment 同时设置天空盒和 IBL,并在场景中放置一个低粗糙度金属球与一个蓝色 Cube。金属球便于检查高光和反射,Cube 则提供相对稳定的漫反射参照。当前源码使用 pillars_2k_skybox.ktx 作为可见背景,使用 lightroom_ibl.ktx 负责间接光照:

java 复制代码
@Override
protected void onSceneReady(SceneLayout sceneLayout) {
    sceneLayout.addIndirectLight(
            "enviroments/light/lightroom_ibl.ktx", 90);
    sceneLayout.setSkybox(
            "enviroments/pillars_2k_skybox.ktx");

    MaterialFactory.makeOpaqueWithColor(
                    requireContext(),
                    new Color(0.86f, 0.88f, 0.92f))
            .thenAccept(material -> {
                if (!isSceneActive()) {
                    return;
                }
                material.setFloat(
                        MaterialFactory.MATERIAL_METALLIC, 1.0f);
                material.setFloat(
                        MaterialFactory.MATERIAL_ROUGHNESS, 0.12f);
                addRenderableNode(
                        GeometryUtils.makeSphere(
                                0.55f, Vector3.zero(), material),
                        new Vector3(-0.62f, 0, -3.0f));
            })
            .exceptionally(error -> {
                Log.e(TAG, "创建天空盒反射球失败", error);
                return null;
            });

    MaterialFactory.makeOpaqueWithColor(
                    requireContext(),
                    new Color(0.14f, 0.48f, 0.92f))
            .thenAccept(material -> {
                if (!isSceneActive()) {
                    return;
                }
                material.setFloat(
                        MaterialFactory.MATERIAL_METALLIC, 0.25f);
                material.setFloat(
                        MaterialFactory.MATERIAL_ROUGHNESS, 0.42f);
                addRenderableNode(
                        GeometryUtils.makeCube(
                                new Vector3(0.9f, 0.9f, 0.9f),
                                Vector3.zero(), material),
                        new Vector3(0.72f, 0, -3.0f));
            })
            .exceptionally(error -> {
                Log.e(TAG, "创建天空盒参考 Cube 失败", error);
                return null;
            });
}

源码地址:SkyboxLessonFragment.java

这段代码最容易产生一个认识误区:调用 setSkybox() 后,物体不会自动获得与背景一致的环境反射。Skybox 是从相机位置观察到的远景背景;IndirectLight 才参与 PBR 光照计算。两者可以独立配置,也可以只启用其中一项。

当前教程特意展示两条独立 API,但 pillars_2k_skybox.ktxlightroom_ibl.ktx 并不是同一份 HDR 环境生成的一对资源,因此金属球反射内容不一定与画面背景严格对应。正式项目若追求"所见即所照",应从同一张 HDR 全景图分别生成 Skybox 与 IBL,并统一曝光、旋转和色彩空间;否则用户会看到背景在柱廊中,而高光似乎来自另一间摄影棚。

3.5 Camera FOV:用垂直视场角控制透视观感

CameraFovLessonFragment 把三个 Cube 放在不同横向位置和不同深度处,将相机垂直 FOV 默认设为60°,可调范围为30°~100°。修改 FOV 不会移动相机或节点,但会改变投影矩阵,因此物体在屏幕中的大小、画面可见范围以及空间纵深感都会一起变化。

java 复制代码
@Override
protected void onSceneReady(SceneLayout sceneLayout) {
    sceneLayout.addIndirectLight(
            "enviroments/light/lightroom_ibl.ktx", 80);
    sceneLayout.getCamera()
            .setVerticalFovDegrees(DEFAULT_FOV);
    sceneLayout.getCamera().setFarClipPlane(100);

    createCube(
            new Vector3(-1.25f, 0, -3.0f),
            new Color(0.05f, 0.48f, 1.0f));
    createCube(
            new Vector3(0, 0, -3.6f),
            new Color(0.22f, 0.78f, 0.48f));
    createCube(
            new Vector3(1.25f, 0, -4.2f),
            new Color(1.0f, 0.52f, 0.08f));
}

@Override
protected void onActionsReady(LinearLayout actionContainer) {
    TextView label = new TextView(requireContext());
    label.setText("FOV:" + DEFAULT_FOV + "°");
    label.setTextColor(0xff333333);
    label.setTextSize(14);
    label.setGravity(Gravity.CENTER_VERTICAL);
    actionContainer.addView(label, new LinearLayout.LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.MATCH_PARENT));

    SeekBar seekBar = new SeekBar(requireContext());
    seekBar.setMax(MAX_FOV - MIN_FOV);
    seekBar.setProgress(DEFAULT_FOV - MIN_FOV);
    seekBar.setOnSeekBarChangeListener(
            new SeekBar.OnSeekBarChangeListener() {
        @Override
        public void onProgressChanged(
                SeekBar seekBar,
                int progress,
                boolean fromUser) {
            int fov = MIN_FOV + progress;
            label.setText("FOV:" + fov + "°");
            if (isSceneActive()) {
                sceneLayout.getCamera()
                        .setVerticalFovDegrees(fov);
            }
        }

        @Override
        public void onStartTrackingTouch(SeekBar seekBar) {
        }

        @Override
        public void onStopTrackingTouch(SeekBar seekBar) {
        }
    });
    actionContainer.addView(seekBar, new LinearLayout.LayoutParams(
            0,
            ViewGroup.LayoutParams.WRAP_CONTENT,
            1));
}

源码地址:CameraFovLessonFragment.java

Slider 的最大进度不是100,而是 MAX_FOV - MIN_FOV,也就是70;默认进度为30,再用 MIN_FOV + progress 映射回真实角度。这样 UI 进度和渲染参数之间的关系清晰,也不会让 SeekBar 的内部进度直接泄漏到相机 API。

在相机位置不变时,37°属于较窄视角:同一物体在屏幕上更大,画面容纳的横向范围更小,视觉上接近长焦。100°属于广角:可见范围明显扩大,中心物体变小,画面边缘的透视拉伸更强。它不是简单的"缩放图片",而是重新定义相机视锥体。移动相机改变的是观察点,修改 FOV 改变的是投影关系,二者不应混为一谈。

3.6 Camera Clip:控制可参与渲染的深度范围

CameraClipLessonFragment 在距相机约1.0 m、3.2 m 和8.0 m 处放置三个 Cube。近裁剪面可在0.1~3.0 m 间按0.1 m 步进调整,远裁剪面可在4~20 m 间按1 m 步进调整。默认值分别为0.1 m 和12 m,因此三个参照物初始都处于有效视锥范围。

java 复制代码
@Override
protected void onSceneReady(SceneLayout sceneLayout) {
    sceneLayout.addIndirectLight(
            "enviroments/light/lightroom_ibl.ktx", 80);
    applyClipPlanes(DEFAULT_NEAR, DEFAULT_FAR);

    createCube(
            new Vector3(-0.7f, 0, -1.0f),
            new Color(1.0f, 0.28f, 0.18f));
    createCube(
            new Vector3(0, 0, -3.2f),
            new Color(0.18f, 0.68f, 1.0f));
    createCube(
            new Vector3(0.8f, 0, -8.0f),
            new Color(0.32f, 0.82f, 0.36f));
}

@Override
protected void onActionsReady(LinearLayout actionContainer) {
    actionContainer.setOrientation(LinearLayout.VERTICAL);

    TextView nearLabel = createSliderLabel(
            formatNearClip(DEFAULT_NEAR));
    SeekBar nearSeekBar = new SeekBar(requireContext());
    nearSeekBar.setMax(NEAR_STEPS);
    nearSeekBar.setProgress(0);
    nearSeekBar.setOnSeekBarChangeListener(
            new SeekBar.OnSeekBarChangeListener() {
        @Override
        public void onProgressChanged(
                SeekBar seekBar,
                int progress,
                boolean fromUser) {
            //desc- 范围 0.1m 到 3.0m,步进 0.1m。
            nearClipPlane = 0.1f + progress * 0.1f;
            nearLabel.setText(
                    formatNearClip(nearClipPlane));
            applyClipPlanes(
                    nearClipPlane, farClipPlane);
        }

        @Override
        public void onStartTrackingTouch(SeekBar seekBar) {
        }

        @Override
        public void onStopTrackingTouch(SeekBar seekBar) {
        }
    });
    actionContainer.addView(
            createSliderRow(nearLabel, nearSeekBar),
            new LinearLayout.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT));

    TextView farLabel = createSliderLabel(
            formatFarClip((int) DEFAULT_FAR));
    SeekBar farSeekBar = new SeekBar(requireContext());
    farSeekBar.setMax(MAX_FAR - MIN_FAR);
    farSeekBar.setProgress((int) DEFAULT_FAR - MIN_FAR);
    farSeekBar.setOnSeekBarChangeListener(
            new SeekBar.OnSeekBarChangeListener() {
        @Override
        public void onProgressChanged(
                SeekBar seekBar,
                int progress,
                boolean fromUser) {
            farClipPlane = MIN_FAR + progress;
            farLabel.setText(
                    formatFarClip((int) farClipPlane));
            applyClipPlanes(
                    nearClipPlane, farClipPlane);
        }

        @Override
        public void onStartTrackingTouch(SeekBar seekBar) {
        }

        @Override
        public void onStopTrackingTouch(SeekBar seekBar) {
        }
    });
    actionContainer.addView(
            createSliderRow(farLabel, farSeekBar),
            new LinearLayout.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT));
}

private void applyClipPlanes(
        float nearPlane, float farPlane) {
    if (!isSceneActive()) {
        return;
    }
    sceneLayout.getCamera().setNearClipPlane(nearPlane);
    sceneLayout.getCamera().setFarClipPlane(farPlane);
}

源码地址:CameraClipLessonFragment.java

裁剪面不是用来控制节点显隐的普通距离开关。它参与相机投影与深度缓冲映射:物体位于 Near 之前或 Far 之后时,相关几何会在裁剪阶段被排除。一个几何体穿过裁剪面时,可能只消失一部分,切面处看起来像模型被剖开,这属于裁剪结果而不是模型数据损坏。

近、远裁剪范围还会影响深度精度。透视投影的深度分布并不线性,精度更多集中在 Near 附近;Near 设得极小而 Far 设得极大,会让有限的深度缓冲精度覆盖过宽范围,增加相邻表面发生 Z-fighting 的风险。工程上应根据场景真实尺度设置"尽可能大的 Near"和"刚好够用的 Far",而不是把范围无限放宽来规避物体消失。

3.7 Shadow:串起光源、投射物和接收面

实时阴影需要三个条件同时成立:Light 开启阴影投射,渲染实例允许作为 ShadowCaster,目标表面允许作为 ShadowReceiver。ShadowLessonFragment 创建橙色 Cube、灰色 Plane 和一盏平行光,通过 Switch 重新构建开关阴影的 Light,直接观察完整链路。

java 复制代码
private void createShadowCaster() {
    MaterialFactory.makeOpaqueWithColor(
                    requireContext(),
                    new Color(0.95f, 0.42f, 0.12f))
            .thenAccept(material -> {
                if (!isSceneActive()) {
                    return;
                }
                Node cubeNode = addRenderableNode(
                        GeometryUtils.makeCube(
                                new Vector3(0.9f, 0.9f, 0.9f),
                                Vector3.zero(), material),
                        new Vector3(0, -0.25f, -3.0f));
                if (cubeNode.getRenderableInstance() != null) {
                    cubeNode.getRenderableInstance()
                            .setShadowCaster(true);
                }
            })
            .exceptionally(error -> {
                Log.e(TAG, "创建阴影投射 Cube 失败", error);
                return null;
            });
}

private void createShadowReceiver() {
    MaterialFactory.makeOpaqueWithColor(
                    requireContext(),
                    new Color(0.72f, 0.74f, 0.78f))
            .thenAccept(material -> {
                if (!isSceneActive()) {
                    return;
                }
                Node planeNode = addRenderableNode(
                        GeometryUtils.makePlane(
                                new Vector3(4.5f, 1.0f, 4.5f),
                                Vector3.zero(), material),
                        new Vector3(0, -0.72f, -3.0f));
                if (planeNode.getRenderableInstance() != null) {
                    planeNode.getRenderableInstance()
                            .setShadowReceiver(true);
                }
            })
            .exceptionally(error -> {
                Log.e(TAG, "创建阴影接收平面失败", error);
                return null;
            });
}

private void updateShadowLight(boolean enabled) {
    if (!isSceneActive() || lightNode == null) {
        return;
    }
    //desc- 重新构建 Light,使开关状态作用于实时阴影。
    Light light = Light.builder(Light.Type.DIRECTIONAL)
            .setColorTemperature(5200)
            .setIntensity(2200)
            .setShadowCastingEnabled(enabled)
            .build();
    lightNode.setLight(null);
    lightNode.setWorldRotation(Quaternion.lookRotation(
            new Vector3(-0.45f, -1.0f, -0.35f),
            Vector3.up()));
    lightNode.setLight(light);
}

源码地址:ShadowLessonFragment.java

本例把 IBL 强度降到10,是为了让直接光与阴影对比更清楚。IBL 提供的是环境间接光,它能避免未被主光照到的区域完全漆黑,但不会替代这盏平行光生成的实时阴影。开关关闭后,Cube 和 Plane 仍会被 IBL 稍微照亮,只是由主光产生的阴影消失。

setShadowCaster(true)setShadowReceiver(true) 作用在 RenderableInstance 上,而不是 Node 本身。原因是节点负责空间层级,渲染实例才对应真正送入渲染器的实体。若代码执行时实例尚未创建,应在 Renderable 挂载完成后再设置;当前 GeometryUtils 同步返回 Renderable,节点赋值后即可取得实例。

四、工程实践:统一管理场景、资源与生命周期

前面的七个 Fragment 各自演示一个知识点,但它们并不是七套互不相关的代码。所有页面都继承 BaseMaterialCameraLessonFragment,再由 BaseTutorialFragmentBaseSampleFragment 接管相机手势与 SceneLayout 生命周期。理解这层公共结构,才能把教程代码迁移到真实业务,而不是复制七份无法维护的 Fragment。

4.1 用公共基类明确 Node 的所有权

三维页面中最常见的资源问题,并不是"不会创建 Node",而是"创建后不知道由谁释放"。公共基类使用 lessonNodes 记录本页挂载的所有可渲染节点和灯光节点,创建入口统一经过 addRenderableNode()addSceneNode()

java 复制代码
private final List<Node> lessonNodes = new ArrayList<>();

protected final Node addRenderableNode(
        Renderable renderable, Vector3 position) {
    Node node = new Node();
    node.setRenderable(renderable);
    node.setWorldPosition(position);
    addSceneNode(node);
    return node;
}

protected final void addSceneNode(Node node) {
    if (!isSceneActive()) {
        return;
    }
    node.setParent(sceneLayout.getRootNode());
    lessonNodes.add(node);
}

源码地址:BaseMaterialCameraLessonFragment.java

这两个方法把空间操作和所有权登记放在同一入口中。业务代码不再到处直接调用 node.setParent(rootNode),也就不容易出现"节点已经显示,却忘记加入清理集合"的情况。灯光节点虽然没有 Renderable,也必须通过 addSceneNode() 登记,因为 Light 同样占用 Filament 实体并参与场景遍历。

需要注意一个边界:addRenderableNode() 在场景失效时仍会返回已经创建但没有挂载的 Node,调用方不应把"返回非 null"理解成"已经加入场景"。当前教程的异步回调都会先检查 isSceneActive(),所以只在有效页面中调用它。若将此基类扩展为公共业务组件,更稳妥的设计是让挂载结果显式返回成功状态,或在上层继续维持同样的活动态检查。

4.2 异步材质回调为什么必须检查页面状态

MaterialFactory.makeOpaqueWithColor()makeTransparentWithColor() 返回异步结果。用户可能在材质创建完成前退出页面,此时 Fragment 的 View 已销毁,sceneLayout 可能已经置空。如果回调仍执行挂载,就会访问旧场景、造成空指针,或者把 Renderable 留在不再显示的节点上。

七个 Lesson Fragment 都采用同一种防护模式:

java 复制代码
MaterialFactory.makeOpaqueWithColor(requireContext(), color)
        .thenAccept(material -> {
            if (!isSceneActive()) {
                return;
            }
            addRenderableNode(renderable, position);
        })
        .exceptionally(error -> {
            Log.e(TAG, "创建材质失败", error);
            return null;
        });

isSceneActive() 同时检查 View 尚未销毁、Fragment 仍处于 Added 状态、sceneLayout 不为空且 RootNode 仍存在。它解决的是"异步结果回来时,接收者是否还活着",并不负责取消底层任务。对于轻量材质创建,结果晚到后直接丢弃即可;如果换成大模型解析、网络下载或 Native 数据构建,还应保存 Future/任务句柄,在 onDestroyView() 中主动取消,减少无用 CPU、IO 和内存峰值。

异常分支同样不能省略。异步链路若没有 exceptionally(),页面可能只表现为"什么都没有",而日志中没有明确的业务上下文。把 Fragment 的 TAG、资源类型和失败动作写入日志,能够快速区分资产路径错误、材质构建错误与页面提前退出。

4.3 Fragment 与 SceneLayout 的生命周期顺序

BaseTutorialFragmentonViewCreated() 创建 CameraGestureController 并绑定到 SceneView;页面销毁时先 detach() 手势,再进入父类清理。BaseSampleFragment 的顺序是:标记 View 已失效、暂停 SceneLayout、移出 View、执行子类 onBeforeDestroyScene()、销毁示例场景、最后调用 sceneLayout.destroy()

公共基类的实际释放代码如下。顺序上先解除 Light 和 Renderable,再断开父子关系,最后清空 Java 集合:

java 复制代码
@Override
protected void onBeforeDestroyScene() {
    for (Node node : lessonNodes) {
        node.setLight(null);
        node.setRenderable(null);
        node.setParent(null);
    }
    lessonNodes.clear();
}

生命周期源码:BaseTutorialFragment.javaBaseSampleFragment.java

这里有三个不能颠倒的判断。第一,手势控制器必须先解绑,否则它可能继续把触摸事件发给即将销毁的 Camera。第二,异步回调的活动态标志要在释放场景之前变为 false,阻止晚到结果继续挂节点。第三,页面独占的 Node/Light/Renderable 先清理,SceneLayout 再销毁;全局 Engine 或共享 glTF 资源不能由单个 Fragment 随意释放,否则其他仍在运行的场景也会受到影响。

4.4 KTX 环境资源的加载与配对原则

SceneLayout.addIndirectLight() 从 assets 读取 KTX,交给 KTX1Loader 创建 IndirectLight,再设置 intensity;setSkybox() 读取另一个 KTX 并创建 Skybox。这两个文件都使用 KTX 容器,但内部数据和渲染职责不同,不能只改文件名就互换使用。

本教程调节 IBL 强度时只调用现有 IndirectLight.setIntensity(),没有在每次 SeekBar 回调中重新读取 KTX。真实应用也应将"资源加载"和"运行时参数调节"分开:KTX 在场景初始化阶段加载一次,亮度、材质参数和相机参数在交互阶段修改对象状态。若拖动滑杆时反复解析 assets,会制造明显卡顿和瞬时内存压力。

五、常见问题与工程排查

5.1 metallic 设为 1 后,金属为什么反而发黑

金属表面的主要颜色来自镜面反射。若场景只有纯色背景、没有有效 IBL,也没有合适的直接光,那么金属没有足够的环境信息可反射,结果就可能接近黑色。此时继续增大 metallic 只会让漫反射贡献更少,问题更明显。

建议先做三项检查:确认 addIndirectLight() 没有因路径错误抛出异常;把 IBL intensity 暂时调到80~100;让同一材质在 roughness 0.1、0.5、0.9下对比。如果低粗糙度球仍没有任何结构化反射,再检查法线、KTX 内容与 Renderer 是否已收到 IndirectLight。如果只有金属球暗而非金属球正常,往往不是几何不可见,而是环境反射链路不完整。

5.2 roughness 越低,为什么噪点、锯齿和环境差异越明显

低 roughness 会让镜面反射能量集中在更窄区域,环境纹理的高频细节、采样误差与资源接缝也更容易暴露;高 roughness 会把反射扩散到更宽范围,看起来更柔和。它不是简单的"模糊滤镜",而是微表面法线分布对反射方向的统计描述。

遇到低粗糙度瑕疵时,应检查环境资源预过滤质量、模型法线/切线、材质参数范围和抗锯齿设置。不要直接把 roughness 全部提高来掩盖问题,因为这会改变材质类型。正式资产还应避免把压缩噪声严重的低动态范围图片直接当作高质量 IBL 来源。

5.3 Skybox 已经显示,为什么物体仍然像没有环境光

因为可见背景和环境照明是两条独立渲染链。setSkybox() 只证明 Skybox 成功加载,不能证明 addIndirectLight() 成功。反过来,没有 Skybox 时 IBL 也可以正常照亮物体,只是背景可能保持默认颜色或透明。

排查时分别验证:先使用粗糙度较低的金属球检查 IBL,再把物体暂时隐藏只检查 Skybox。若二者均有效但反射内容与背景不一致,应检查是否来自同一 HDR,以及环境旋转是否一致。当前教程使用不同名称的两份环境资源,主要用于展示 API 职责;生产项目应按配对原则重新组织资源。

5.4 切换 Point Light 或 Spotlight 后,物体为什么突然变暗

Directional Light 只依赖方向,不会因节点离物体远近而衰减;Point Light 和 Spotlight 则必须同时满足位置、Falloff 范围和强度条件。Spotlight 还要求光锥朝向目标,目标若落在外锥之外,即使强度很高也不会被有效照亮。

先把灯放在物体附近并增大 Falloff,再使用 Quaternion.lookRotation(target - position, Vector3.up()) 对准目标。确认位置与朝向正确后,再恢复合理强度和衰减半径。如果直接把 intensity 提高几个数量级,可能暂时照亮对象,却会破坏曝光并掩盖光源实际没有对准的问题。

5.5 修改 FOV 后像"相机移动了",这是错误吗

不是。FOV 改变投影矩阵,同样会改变物体在屏幕上的尺寸,因此视觉上很像拉近或推远镜头。但节点世界坐标与相机世界位置都没有变化。窄 FOV 通常让中心物体更大、可见范围更小;广 FOV 让场景容纳更多内容,同时强化边缘透视。

如果需要验证,可在修改前后打印 Camera 的 worldPosition,并固定三枚 Cube 的 Transform。位置不变但屏幕投影变化,说明行为正确。交互中若同时启用了捏合移动相机,建议先调用教程的相机复位功能,再比较 FOV 两端,避免两个变量同时变化。

5.6 调整裁剪面后,模型只剩一半是不是模型坏了

不一定。当几何体与 Near 或 Far 平面相交时,视锥范围外的三角形片段会被裁掉,画面上可能只剩部分模型。这与删除网格顶点不同,恢复裁剪范围后几何仍会完整显示。

先查看模型到相机的真实距离和包围盒大小,再判断裁剪面是否穿过模型。不要只看 Node 原点:一个大型模型的原点可能在有效范围内,但边缘已经越过裁剪面。对于尺寸跨度很大的场景,还应检查模型单位是否统一,以及 Near 过小、Far 过大是否正在损失深度精度并引发 Z-fighting。

5.7 阴影开关打开了,为什么地面上没有阴影

按三段链路逐项检查:

  1. Light 是否调用 setShadowCastingEnabled(true),并实际挂到场景中的 Node;
  2. 投射物的 RenderableInstance 是否设置 setShadowCaster(true)
  3. 接收面的 RenderableInstance 是否设置 setShadowReceiver(true)

三者都成立后,再检查光线方向是否让阴影落在接收面上、Caster 和 Receiver 是否相交或距离过远、接收面法线是否合理。若开关前后画面完全没有差别,可暂时降低 IBL、提高直接光与背景的对比度。阴影其实存在但太浅时,问题属于视觉对比;阴影实体根本没有生成时,才是配置链路问题。

5.8 快速退出页面后偶发崩溃或残留灯光,应该查哪里

优先检查异步回调和清理顺序。所有 thenAccept() 在触碰 SceneLayout 前都应调用 isSceneActive();所有 Light Node、Renderable Node 都应进入统一的所有权集合;onBeforeDestroyScene() 必须在 SceneLayout.destroy() 前执行。手势、动画、定时任务或场景更新监听也要在销毁前解绑。

如果问题只在连续进出时出现,可在创建、回调、清理三个位置记录 Fragment 实例标识与 Node 数量,确认晚到回调属于哪次页面实例。不要用全局静态变量保存当前 SceneLayout 或 Light 来"避免为空",这会延长旧页面生命周期,反而使泄漏和跨页面串场更难排查。

5.9 编译通过但真机显示不同,如何区分资源问题和设备问题

先在同一 APK、同一资源和同一相机参数下比较设备,避免将构建差异误判为 GPU 差异。检查设备是否满足 arm64-v8a、Android API 24+ 和 OpenGL/Filament 所需能力;再查看日志中是否有 KTX 解析、Shader、Native 库或内存相关错误。

若只有透明边缘、阴影精度或高光锯齿存在轻微差异,可能与分辨率、驱动和 GPU 实现有关;若整个模型消失、IBL 完全无效或进入页面即崩溃,更应先检查资源打包、ABI、路径大小写与生命周期。建立一台已知正常的基准设备,可以显著缩小排查范围。

六、本篇总结与延伸阅读

6.1 把画面问题还原成四层职责

Part 03 的核心不是记住若干强度值,而是建立一套可解释的画面模型:Material 决定表面如何响应光,Light 与 IBL 决定光从哪里来,Skybox 决定相机看到什么背景,Camera 与深度/阴影系统决定哪些结果最终进入画面。

层级 本文对象 关键问题 优先验证方式
表面 baseColor、metallic、roughness、alpha 材质是金属、介电质、光滑还是粗糙 固定灯光,一次只改一个参数
照明 Directional、Point、Spotlight、IBL 光的方向、位置、范围和环境信息是否成立 使用简单球体/Cube 对比直接光与环境光
背景 Skybox 背景是否显示,是否与环境反射配对 分别验证 Skybox 与 IBL,再检查同源性
观察 FOV、Near、Far 投影关系和可见深度范围是否合理 固定 Transform,对比 FOV 与裁剪面两端
阴影 Light、Caster、Receiver 完整阴影链是否同时启用 降低 IBL,逐项开关三类配置
生命周期 Future、Node、Renderable、SceneLayout 页面退出后是否仍持有场景资源 快速进出、检查晚到回调和节点清理

当物体"发黑、消失、比例怪、没有阴影"时,先判断它属于哪一层,再沿该层的输入和输出排查。分层定位比随机修改参数更快,也更容易在团队中复现和讨论。后续无论加入 GLB 动画、视频纹理还是 AR 相机,这套材质---照明---观察---生命周期链仍然适用。

6.2 本篇源码索引

项目入口:eqgis/Sceneform-EQRtutorial 完整目录

6.3 专业术语与参考资料

下一篇将继续沿教程 Fragment 主线进入模型动画:从 GLB 动画资源、AnimationData/Animator、播放控制和节点变换开始,讨论动画更新如何进入每帧渲染,以及页面退出时如何停止动画并释放引用。

相关推荐
Dovis(誓平步青云)2 小时前
《 固井工程软件 Cemsol 的数据管理与国产化适配实践》
android·java·开发语言·人工智能
Kapaseker2 小时前
小白都看得懂的 Skill 教程 - 创建第一个 Skill
android·kotlin·vibecoding
zhangphil2 小时前
Android BitmapFactory实现AOSP ContentResolver.loadThumbnail快速取小缩略图,Kotlin
android·kotlin
weixin_440784112 小时前
【OkHttp实现原理】
android·java·okhttp
恋猫de小郭2 小时前
Jetpack Compose 8 月版正式发布,核心模块 1.12
android·前端·flutter
弈语道破AI2 小时前
3D渲染不再熬时间!即梦 Seedance 2.5 具备3D白模渲染功能的AI视频生成工具
人工智能·3d·音视频
delta_hell2 小时前
【阅读源码--Android】动画之AnimatorSet--1
android·源码·animatorset
答案—answer14 小时前
VibeCoding 能做到什么程度?我用它做了一座 3D 数字博物馆
3d·ai编程·threejs·vibecoding
2501_9159214314 小时前
appuploader-cli 命令行上传 IPA 到 App Store Connect upload CI 集成
android·ci/cd·小程序·https·uni-app·iphone·webview