HarmonyOS 7 / API 26 3DGS 模型首屏黑屏排查:相机、灯光和资源包围盒实战
HarmonyOS 7 / API 26 3DGS 模型首屏黑屏排查:相机、灯光和资源包围盒实战
这个问题比“模型加载失败”更隐蔽
3DGS 或 3D 模型接入时,经常会遇到一种很烦的问题:接口没有报错,模型文件也确实加载了,但页面第一屏就是黑的,或者只看到一小块漂在角落里。开发者第一反应容易去怀疑模型格式,结果查半天发现文件没坏,问题出在相机、灯光、模型包围盒和首帧状态上。
HarmonyOS 7 / API 26 的 3DGS 端侧重建方向,不能只看“模型能不能被加载”。真正在应用里交付时,要看用户第一次进入页面能不能稳定看到主体。首屏看不到主体,后面旋转、缩放、滤镜做得再多也没意义。
我会把这个问题分成四类:
| 现象 | 常见原因 | 先查什么 |
| 页面全黑 | 没有有效灯光、背景和模型颜色接近、材质参数异常 | 默认灯光、背景色、材质 |
| 模型太小 | 相机距离太远,模型包围盒没算 | 包围盒、相机距离 |
| 只看到一角 | 相机朝向不对,模型中心点偏移 | 模型中心点、lookAt 目标 |
| 首次正常,返回异常 | 场景释放和重建不完整 | 页面生命周期、scene dispose |
这篇不讨论端侧重建算法本身,只讨论模型已经存在以后,如何让 ArkGraphics 3D 侧的首屏预览稳定下来。
官方能力边界先定住
Spatial Recon Kit 负责 3DGS 相关的重建和资源能力,ArkGraphics 3D 负责把资源放进场景里,提供相机、灯光、节点、材质、动画等能力。也就是说,首屏黑屏这类问题,大多数不应该回头去重跑重建,而应该先检查 3D 场景侧。
一个比较稳的判断顺序是:
- 文件是否存在,大小是否异常;
- 场景是否初始化成功;
- 模型是否有包围盒数据;
- 相机是否对准模型中心;
- 灯光是否能照亮主体;
- 首帧是否有加载完成和失败兜底。
案例一:模型加载成功,但相机没对准
第一个案例很常见:模型资源加载成功,场景也没有报错,但用户看到的是空页面。这种情况下,先不要急着换模型,先把模型的中心点和包围盒打印出来。
复现步骤
- 加载一个模型资源;
- 不设置默认相机,只使用引擎默认视角;
- 页面打开后观察首屏;
- 打印模型中心点、宽高深和相机位置;
- 根据包围盒重置相机,再观察首屏是否恢复。
interface Vec3 { x: number; y: number; z: number; } interface ModelBounds { center: Vec3; size: Vec3; radius: number; } interface CameraPose { eye: Vec3; target: Vec3; up: Vec3; } export class CameraPresetBuilder { build(bounds: ModelBounds): CameraPose { const safeRadius = Math.max(bounds.radius, 1); const distance = safeRadius * 2.8; return { eye: { x: bounds.center.x, y: bounds.center.y + safeRadius * 0.45, z: bounds.center.z + distance }, target: bounds.center, up: { x: 0, y: 1, z: 0 } }; } }这段代码的核心是用模型包围盒反推相机位置。很多黑屏问题不是模型没有加载,而是相机离得太远、太近,或者根本没有看向模型中心。
页面里要保留首帧状态
type FirstFramePhase = 'idle' | 'loading' | 'visible' | 'empty' | 'failed'; interface FirstFrameState { phase: FirstFramePhase; message: string; bounds?: ModelBounds; } export class FirstFrameProbe { private state: FirstFrameState = { phase: 'idle', message: '' }; start(): void { this.state = { phase: 'loading', message: '正在准备 3D 首帧' }; } visible(bounds: ModelBounds): void { this.state = { phase: 'visible', message: '模型首帧已显示', bounds }; } empty(reason: string): void { this.state = { phase: 'empty', message: reason }; } failed(error: Error): void { this.state = { phase: 'failed', message: error.message }; } snapshot(): FirstFrameState { return { ...this.state }; } }这里不要只写一个 loading。首帧问题需要分清“加载中”“已显示”“空画面”“失败”。这四种状态给用户看到的 UI 不一样,给开发者看的日志也不一样。
案例二:模型在,但灯光和背景让它看起来像没显示
第二个案例也很常见:模型确实在场景里,但颜色很暗,背景也暗,最后用户看到的是一片黑。这个时候继续改资源路径没有用,要处理默认灯光和背景。
复现步骤
- 使用深色背景;
- 加载一个暗色模型;
- 不配置环境光和主光源;
- 打开页面观察首屏;
- 加入默认环境光、主光源和轮廓光;
- 再观察模型边缘和主体是否可见。
type LightRole = 'ambient' | 'key' | 'rim'; interface LightPreset { role: LightRole; intensity: number; color: string; direction?: Vec3; } export class SceneLightPresetFactory { buildDefault(): LightPreset[] { return [ { role: 'ambient', intensity: 0.35, color: '#FFFFFF' }, { role: 'key', intensity: 0.9, color: '#FFF7ED', direction: { x: -0.4, y: -0.8, z: -0.2 } }, { role: 'rim', intensity: 0.45, color: '#93C5FD', direction: { x: 0.5, y: -0.2, z: 0.8 } } ]; } }我会保留三层光:环境光保证整体不黑,主光源保证主体有明暗关系,轮廓光保证模型边缘能从背景里分出来。不是所有项目都需要复杂灯光,但默认灯光不能没有。
首帧验收不要只靠肉眼
interface FirstFrameCheckResult { hasAsset: boolean; hasBounds: boolean; cameraReady: boolean; lightReady: boolean; message: string; } export class FirstFrameChecker { check(asset: SpatialAsset, bounds: ModelBounds | undefined, camera: CameraPose | undefined, lights: LightPreset[]): FirstFrameCheckResult { if (!asset.localUri || asset.byteSize <= 0) { return { hasAsset: false, hasBounds: false, cameraReady: false, lightReady: false, message: '模型资源无效' }; } if (!bounds || bounds.radius <= 0) { return { hasAsset: true, hasBounds: false, cameraReady: false, lightReady: false, message: '模型包围盒异常' }; } if (!camera) { return { hasAsset: true, hasBounds: true, cameraReady: false, lightReady: false, message: '默认相机未设置' }; } if (lights.length === 0) { return { hasAsset: true, hasBounds: true, cameraReady: true, lightReady: false, message: '缺少默认灯光' }; } return { hasAsset: true, hasBounds: true, cameraReady: true, lightReady: true, message: '首帧检查通过' }; } }这个检查器的作用是把“看起来没显示”变成几个可以判断的条件。资源、包围盒、相机、灯光只要有一个没准备好,就不要把页面当成成功态。
推荐的接入结构
我会把 3DGS 预览页拆成四个小模块:
模块 负责内容 不负责内容 SpatialAssetRepository 资源路径、大小、格式、封面图 相机、灯光、页面布局 CameraPresetBuilder 根据包围盒生成默认相机 模型加载、重建会话 SceneLightPresetFactory 生成默认灯光组合 页面状态和用户交互 FirstFrameChecker 判断首屏是否真的可见 修复模型文件本身 这四个模块单独看都不复杂,但组合起来能解决很多首屏问题。后面换模型、换设备、换横竖屏,也不用每次都从页面里复制一堆判断。
export class SpatialPreviewBootstrap { private cameraBuilder = new CameraPresetBuilder(); private lightFactory = new SceneLightPresetFactory(); private checker = new FirstFrameChecker(); async prepare(asset: SpatialAsset, scene: ThreeDSceneController): Promise<FirstFrameCheckResult> { await scene.init('spatial-preview-surface'); await scene.loadAsset(asset); const bounds = await this.readBounds(asset); const camera = this.cameraBuilder.build(bounds); const lights = this.lightFactory.buildDefault(); await scene.applyDefaultCamera(); await scene.applySoftLight(); return this.checker.check(asset, bounds, camera, lights); } private async readBounds(asset: SpatialAsset): Promise<ModelBounds> { return { center: { x: 0, y: 0, z: 0 }, size: { x: 1.2, y: 1.8, z: 1.2 }, radius: 1.2 }; } }这里的代码不是要替代官方接口,而是给接入结构定边界。实际项目里读取包围盒、设置相机、创建灯光都要按当前 SDK 写法接上。结构先稳住,接口替换起来才不乱。
最后总结
3DGS 首屏黑屏不要只盯着“模型有没有加载”。更实际的排查顺序是:资源存在、场景初始化、包围盒有效、相机对准、灯光可见、失败有兜底。
HarmonyOS 7 / API 26 的 3DGS 能力很适合做空间展示,但越是新能力,越不能只追一个成功截图。首屏预览是用户接触 3D 内容的第一秒,这一秒如果黑屏、偏移、太暗或者没兜底,后面的交互都白搭。把相机、灯光和首帧检查做成可复用模块,后续接不同模型、不同设备和不同页面都会稳很多。
