HarmonyOS 7 ArkGraphics 3D:二进制PLY端序解析与Splat异常隔离
一、模型能加载,不代表这批 Splat 值得送进 GPU
SplatGate 起初只是一个内部模型预览器。重建服务导出 atrium_bad_v12.ply,应用读取文件后创建 3DGS 节点,右侧预览很快出现了场景。问题也紧跟着出现:相机转到玻璃顶附近时,画面突然拉出一条贯穿屏幕的亮带,随后帧率从 55 fps 掉到 11 fps。日志里没有文件读取失败,也没有 GPU 创建错误,模型甚至能继续旋转。
我先怀疑 Shader,再怀疑球谐系数范围,最后在第 884211 个顶点找到 scale_2 = NaN。它前面的 71% 数据完全正常,所以“头部可读、长度匹配、节点能创建”这三条判断都没有拦住问题。更危险的是,旧实现边读边上传:发现坏点时,前 8 个 GPU 分块已经创建,只能依赖复杂的逆向释放把半个场景拆掉。

本轮 Demo 固定任务号 PLY-2118,页面 PlyAuditPage,时间 21:18。坏包 atrium_bad_v12.ply 声明 binary_little_endian 1.0、1,248,320 个 vertex、每点 248 字节、SH degree 3。状态从 READING_HEADER 进入 VALIDATING_LAYOUT 和 SCANNING_VERTICES,在 71% 转为 QUARANTINED,错误为 PLY_NON_FINITE_SCALE,GPU buffer 数保持 0。修复后的 atrium_scan_v12.ply 走到 UPLOADING → READY,12 个分块全部提交。
二、这次不再从渲染异常倒推文件格式
二进制 PLY 最容易制造一种错觉:文件大小大致对得上,就认为结构没问题。实际上,header 同时定义了端序、元素数量和属性顺序。只要服务端多插入一个 float,客户端仍按旧 stride 读取,后面所有字段都会错位;如果把 little-endian 当成 big-endian,部分接近 0 的数值看上去甚至还“像真的”。
因此这次把入库拆成三个相互独立的阶段。第一阶段只读到 end_header,不创建模型对象;第二阶段用 header 构造严格 layout,确认 62 个 float 属性、248 字节 stride 和 1,248,320 个顶点;第三阶段顺序扫描全部记录,产出统计报告。报告通过前,GpuSplatUploader 根本拿不到文件句柄。
项目结构也按责任拆开:
SplatGate/
├── entry/src/main/ets/pages/PlyAuditPage.ets
├── entry/src/main/ets/ply/PlyHeaderReader.ets
├── entry/src/main/ets/ply/PlyLayout.ets
├── entry/src/main/ets/ply/SplatValidator.ets
├── entry/src/main/ets/ply/AuditReport.ets
└── entry/src/main/ets/render/GpuSplatUploader.ets
页面只消费 AuditSnapshot,不会直接持有 ArrayBuffer 或 GPU 资源。这样用户切走页面、任务被取消、同一文件重复检测时,资源所有权仍留在服务层,不会出现页面和上传器各释放一次的情况。
三、header 必须生成 layout,不能只读 vertex 数
第一段代码解决的是“字段顺序漂移仍被当成旧格式”。读取器限制 header 最长 64 KB,遇到 end_header 立即停止;它只接受 binary_little_endian 1.0,并把 vertex 的 property 顺序完整保留下来。当前渲染器尚未支持 big-endian,与其隐式猜测,不如明确返回 PLY_ENDIAN_UNSUPPORTED。
// PlyHeaderReader.ets
export interface PlyHeader {
format: string
vertexCount: number
properties: string[]
headerBytes: number
}
export function parseHeader(bytes: Uint8Array): PlyHeader {
const end = findAscii(bytes, 'end_header\n', 64 * 1024)
if (end < 0) throw new Error('PLY_HEADER_INCOMPLETE')
const lines = decodeAscii(bytes.subarray(0, end)).split('\n')
const format = valueAfter(lines, 'format ')
if (format !== 'binary_little_endian 1.0') {
throw new Error('PLY_ENDIAN_UNSUPPORTED')
}
const vertexCount = Number(valueAfter(lines, 'element vertex '))
const properties = collectVertexProperties(lines)
if (vertexCount !== 1248320 || properties.length !== 62) {
throw new Error('PLY_LAYOUT_MISMATCH')
}
return { format, vertexCount, properties, headerBytes: end }
}
这里把“当前 Demo 的契约”写得很硬:顶点数和属性数必须匹配 manifest。真实产品可以允许多个受支持版本,但每个版本仍要有独立 schema,不应只判断 properties.length >= 62。否则新增属性插在中间时,旧偏移会悄悄读错。
headerBytes 记录的是 payload 起点,后续会用 headerBytes + vertexCount × stride 与文件真实长度比较。少 1 字节也算 PLY_PAYLOAD_TRUNCATED,多出的尾部内容也不会被忽略。这样截断包在扫描前就能失败,不必等到 DataView 抛越界异常。
四、逐点验证不只查 NaN,还要判断组合是否合法
第二段代码解决单个字段合法、组合却无法构成稳定 Splat 的情况。位置、opacity、scale、旋转四元数和 48 个 SH 系数都必须是有限数;scale 的对数值限制在 [-12, 8];四元数模长必须落在 [0.5, 1.5],随后才归一化。这个范围不是数学真理,而是当前重建链的工程门槛。
// SplatValidator.ets(偏移由 PlyLayout 生成)
validate(view: DataView, base: number, index: number): SplatIssue | null {
const f32 = (offset: number): number => view.getFloat32(base + offset, true)
const position = [f32(this.x), f32(this.y), f32(this.z)]
const scale = [f32(this.s0), f32(this.s1), f32(this.s2)]
const rotation = [f32(this.r0), f32(this.r1), f32(this.r2), f32(this.r3)]
const opacity = f32(this.opacity)
const sh = this.shOffsets.map(offset => f32(offset))
if (![...position, ...scale, ...rotation, opacity, ...sh].every(Number.isFinite)) {
return { index, code: 'PLY_NON_FINITE_SCALE' }
}
if (scale.some(value => value < -12 || value > 8)) {
return { index, code: 'PLY_SCALE_OUT_OF_RANGE' }
}
const qNorm = Math.hypot(...rotation)
if (qNorm < 0.5 || qNorm > 1.5 || sh.length !== 48) {
return { index, code: 'PLY_SPLAT_SHAPE_INVALID' }
}
return null
}
实际坏包在 index 884211 返回 PLY_NON_FINITE_SCALE。代码没有继续扫描并收集几万个错误,而是在保存首个错误、当前进度和字段偏移后立刻停止。对这个场景,首错足以证明文件不可上传;继续运行只会浪费电量和时间。
扫描使用 16 MB 窗口映射,跨窗口的最后一条记录会复制到 248 字节的拼接缓冲区,因此峰值内存最终是 72.4 MB,而不是把 309.6 MB payload 全部读入内存。窗口和 DataView 每轮结束后立即失去引用;页面退出时取消 generation 21,旧扫描回调只能写收口日志,不能覆盖 generation 22 的重试结果。
五、上传门禁要放在资源创建之前
旧实现的问题并非少写一个 if,而是验证器和上传器共享同一条流。第三段代码把 AuditReport.accepted 变成唯一入口;上传按 12 个 chunk 执行,每个 chunk 创建成功后登记租约,任何异常都按相反顺序释放。
// GpuSplatUploader.ets
async upload(task: AuditTask, report: AuditReport): Promise<void> {
if (!report.accepted || report.invalidVertices !== 0) {
throw new Error('GPU_UPLOAD_BLOCKED')
}
const generation = task.generation
const leases: BufferLease[] = []
try {
for (let chunk = 0; chunk < 12; chunk++) {
this.assertCurrent(generation)
const bytes = await this.source.readValidatedChunk(report, chunk)
leases.push(await this.device.createSplatBuffer(bytes))
task.uploadedChunks = chunk + 1
this.emit(task, 'UPLOADING')
}
task.state = 'READY'
this.activeModel.replace(leases)
} catch (error) {
leases.reverse().forEach(lease => lease.dispose())
throw error
}
}
关键点是 activeModel.replace 只在 12/12 完成后发生。旧模型在此之前仍可显示,用户不会看到半个新场景。若第 9 个分块创建失败,前 8 个租约全部释放,旧模型保持不变;重复点击“重新检测”也不会让两个上传器同时工作。

六、日志里的 71% 比一张花屏截图更有用
本次调试把 header 契约、扫描窗口、错误顶点和 GPU 门禁写在同一 taskId 下。坏包的关键日志是:
21:18:03.108 SplatGate PLY-2118 READING_HEADER format=binary_little_endian vertices=1248320
21:18:03.442 SplatGate PLY-2118 VALIDATING_LAYOUT stride=248 sh=48 payload=309.6MB
21:18:05.917 SplatGate PLY-2118 QUARANTINED progress=71% vertex=884211 error=PLY_NON_FINITE_SCALE
21:18:05.928 SplatGate PLY-2118 GPU_UPLOAD_BLOCKED buffers=0 peak=72.4MB
修复包使用 generation 22,扫描结果 valid=1248320 invalid=0,随后 12/12 分块在 1.84 秒内上传,状态 READY。日志不打印完整 SH 数组,也不打印用户沙箱绝对路径;只保留能复现 schema 与错误位置的字段。
UI 刷新也做了节流。扫描每完成约 8192 个顶点才发布一次快照,页面显示进度不会每条记录重组。进入后台时任务立即取消,关闭映射窗口;这个 Demo 不承诺后台持续导入,因此不会申请额外后台能力。
七、最终结果是“没有坏数据进入 GPU”
手机结果页同时保留两次检测。历史卡中的任务 PLY-2117 是坏包首次入库记录;为确认门禁可复现,我在当前任务 PLY-2118 的 generation 21 再跑同一文件,因此 IDE 日志归在 PLY-2118。generation 21 的 atrium_bad_v12.ply 在 71% 被隔离,错误顶点 884211,GPU buffers 为 0;generation 22 的 atrium_scan_v12.ply 完成 1,248,320/1,248,320 顶点扫描,SH 48/48、非法数 0、12/12 分块上传,最终 READY。

页面上的“协方差可构造 100%”来自 scale 范围与四元数归一化结果,不是渲染后肉眼判断。按钮只有“重新检测”和“加载场景”;处于 QUARANTINED 时加载按钮禁用,避免开发测试为了看一眼效果绕过门禁。
这次性能也可解释:309.6 MB payload 扫描耗时 2.49 秒,GPU 上传 1.84 秒,峰值内存 72.4 MB。数据只是本机验收值,不代表所有设备;真正有用的是这些数字与 taskId、文件 schema、分块数处于同一份报告中,后续回归才能比较。
八、边界:格式门禁不是模型质量评分
SplatGate 能证明文件符合当前二进制契约、关键字段有限、Splat 形状可构造,却不能证明重建内容好看。浮点值都合法的模型仍可能覆盖不足、重影严重或颜色偏移,这些属于采集质量与渲染验收,不应塞进 PLY 解析器。
另一个边界是版本演进。下一版如果增加 SH degree 4,不能把 48 改成更大的常量后继续复用旧 schema;应该新建 layoutId,升级 manifest,并让旧客户端明确报“不支持的布局”。同样,未来若支持 big-endian,应单独实现端序路径和测试样本,而不是在现有读取器里悄悄翻转一个布尔值。
回头看,这次修掉的不是一条亮带,而是“能读就能传、能传就能渲染”的错误链路。让 header 生成契约、让扫描报告决定资格、让 GPU 上传保持原子替换,坏模型才真正被挡在渲染资源之外。
更多推荐



所有评论(0)