示例项目:SplatGuard
页面:RenderabilityAuditPage

3DGS 重建链路已经返回完成,产物文件也存在,接入渲染器后却可能只得到黑屏、飞散的巨大色块,甚至在某一帧出现 GPU 异常。此时继续调相机、曝光和材质通常收效不大,因为问题也许发生在渲染之前:少量高斯记录里混入了 NaN、Infinity 或越界透明度。

这篇文章不把自定义的 splat 文件格式写成 HarmonyOS 官方输出协议。Spatial Recon 的官方流程负责空间重建能力;SplatGuard 从项目已经完成的结果转换边界开始工作,把项目自己的统一记录流送入 C++ 扫描器。文中的 SplatRecord、scanNormalizedStream 与 RenderabilityGate 都是示例项目内的适配层,不是系统 API。

一、任务完成不等于产物一定可渲染

一次重建通常跨越采集、计算、结果导出、格式转换和渲染多个阶段。上游任务“成功”只证明它完成了自己的职责,不代表下游自定义转换器没有字节序错误、字段错位、截断记录或单位混用。只要一条 scale 变成无穷大,包围盒就可能被拉到不可用范围;只要 position 中出现 NaN,后续矩阵计算就会持续传播非有限值。

SplatGuard 处理的演示资产叫 lamp_scan_g21,任务 ID 为 SPLAT-GUARD-0073。转换层提供 248,320 条归一化高斯记录,扫描后 248,214 条通过,106 条进入隔离区:18 条位置含 NaN,7 条尺度含 Infinity,81 条透明度超出 [0, 1]。无效率为 0.043%,低于门禁阈值 0.10%,因此结果为 PASS。

这些数字是为了建立一份自洽的演示快照,并非真实设备实测。更重要的是状态定义:ARTIFACT_READY → STREAM_SCANNING → QUARANTINED → RENDERABLE。只有最后一个状态允许把干净记录交给渲染器。

二、先建立项目内的归一化边界

不同重建或转换工具可能使用不同字段布局、精度和尺度约定。扫描器不应直接猜测某个二进制文件,而应让格式适配器先输出项目约定的记录。示例只保留位置、对数尺度、透明度和颜色这几类检查所需字段;旋转、球谐系数等字段可以按渲染器契约继续扩展。

这段代码解决什么问题。 它定义项目内可验证的记录,并在最靠近输入的位置做有限值与范围检查。

#include <array>
#include <cmath>
#include <cstdint>

struct SplatRecord {
  std::array<float, 3> position;
  std::array<float, 3> logScale;
  float opacity;
  std::array<float, 3> color;
  std::uint64_t sourceOffset;
};

enum class RejectReason {
  NONE,
  POSITION_NON_FINITE,
  SCALE_NON_FINITE,
  OPACITY_OUT_OF_RANGE,
  COLOR_NON_FINITE
};

RejectReason validate(const SplatRecord& s) {
  for (float v : s.position) {
    if (!std::isfinite(v)) return RejectReason::POSITION_NON_FINITE;
  }
  for (float v : s.logScale) {
    if (!std::isfinite(v)) return RejectReason::SCALE_NON_FINITE;
  }
  if (!std::isfinite(s.opacity) || s.opacity < 0.0F || s.opacity > 1.0F) {
    return RejectReason::OPACITY_OUT_OF_RANGE;
  }
  for (float v : s.color) {
    if (!std::isfinite(v)) return RejectReason::COLOR_NON_FINITE;
  }
  return RejectReason::NONE;
}

这里使用 std::isfinite,因为仅判断 value != value 只能识别 NaN,识别不了正负 Infinity。透明度除了检查有限值,还检查业务范围。状态仍停留在 ARTIFACT_READY,因为单条验证函数本身不拥有文件,也不决定整批是否可渲染。

容易出错的是直接把异常值“修成 0”。位置 NaN 被改为原点后,所有坏记录会堆在场景中心;巨大 scale 被截断后,也可能形成不属于原始重建的几何。SplatGuard 的默认策略是隔离并保留 sourceOffset,便于回查转换器,而不是静默修复。

三、流式扫描比一次性装入更适合门禁

248,320 条记录并不算天文数字,但完整记录可能包含更多球谐参数。把所有原始数据和诊断副本同时放进内存,会让门禁本身变成峰值占用来源。扫描器采用流式输入:读取一条、验证一条、更新统计;通过项写入干净流,拒绝项只保存有限数量的诊断样本和计数。

这段代码解决什么问题。 它在不保存整份诊断副本的情况下统计 106 条异常,并把有效记录送往隔离后的输出流。

struct AuditStats {
  std::uint64_t total = 0;
  std::uint64_t accepted = 0;
  std::uint64_t nanPosition = 0;
  std::uint64_t infScale = 0;
  std::uint64_t opacityOutOfRange = 0;
};

template<class Reader, class CleanWriter, class QuarantineWriter>
AuditStats scanNormalizedStream(
    Reader& reader,
    CleanWriter& clean,
    QuarantineWriter& quarantine) {
  AuditStats stats;
  SplatRecord item {};
  while (reader.next(item)) {
    ++stats.total;
    const RejectReason reason = validate(item);
    if (reason == RejectReason::NONE) {
      clean.write(item);
      ++stats.accepted;
      continue;
    }
    quarantine.write(item.sourceOffset, reason);
    if (reason == RejectReason::POSITION_NON_FINITE) ++stats.nanPosition;
    if (reason == RejectReason::SCALE_NON_FINITE) ++stats.infScale;
    if (reason == RejectReason::OPACITY_OUT_OF_RANGE) {
      ++stats.opacityOutOfRange;
    }
  }
  return stats;
}

扫描开始时状态进入 STREAM_SCANNING,发现坏记录后不是立即失败,而是持续完成统计,最终进入 QUARANTINED。这样可以一次回答“坏了多少、坏在哪里、主要是哪一类”,而不是每次修掉第一条后再重新跑一遍。

模板参数表示项目注入的读取器和写入器,不对应某个系统类型。实际实现应检查 reader.next 是否区分正常 EOF 与截断错误;若文件在半条记录处结束,不能把它当作自然结束。输出流也要使用临时文件并在成功后原子替换,避免崩溃留下半份“干净产物”。

四、包围盒必须只由通过项计算

有限值检查只是第一层。一条数值有限但位置达到数万米的记录,仍会让相机裁剪面和场景尺度失常。SplatGuard 用通过项计算 AABB,并在业务层判断宽、高、深是否符合目标对象。演示资产的包围盒为 1.82 × 0.94 × 1.26 m。

这段代码解决什么问题。 它从有效记录增量计算包围盒,并根据无效率与尺寸契约得出门禁结果。

#include <algorithm>
#include <limits>

struct Bounds {
  std::array<float, 3> min {
    std::numeric_limits<float>::infinity(),
    std::numeric_limits<float>::infinity(),
    std::numeric_limits<float>::infinity()
  };
  std::array<float, 3> max {
    -std::numeric_limits<float>::infinity(),
    -std::numeric_limits<float>::infinity(),
    -std::numeric_limits<float>::infinity()
  };

  void include(const SplatRecord& s) {
    for (size_t i = 0; i < 3; ++i) {
      min[i] = std::min(min[i], s.position[i]);
      max[i] = std::max(max[i], s.position[i]);
    }
  }
};

bool renderable(const AuditStats& s, const Bounds& b) {
  if (s.total == 0 || s.accepted == 0) return false;
  const double invalidRate =
    static_cast<double>(s.total - s.accepted) / static_cast<double>(s.total);
  const float width = b.max[0] - b.min[0];
  const float height = b.max[1] - b.min[1];
  const float depth = b.max[2] - b.min[2];
  return invalidRate <= 0.001 &&
    width <= 2.20F && height <= 1.20F && depth <= 1.50F;
}

阈值 0.001 对应 0.10%。106 除以 248,320 约为 0.043%,因此通过第一项;包围盒也落在灯具扫描场景的尺寸上限内。这里的尺寸不是 Spatial Recon 的通用限制,而是 SplatGuard 这个 Demo 的业务契约,换成房间扫描必须重新定义。

包围盒只能由有效记录计算。若先把含 Infinity 的 scale 或 position 纳入统计,再决定隔离,min/max 已经被污染。还要处理坐标单位:适配器必须把输入统一到米,并在 manifest 中保存单位来源,门禁不能靠“看起来像米”猜测。

DevEco Studio 风格配图展示的是演示结构,不冒充真实调试截图。左侧工程树包含 native/splat_guard.cpp 和 RenderabilityAuditPage.ets,中间圈出 std::isfinite 与 0.10% 门禁,右侧模拟器显示 248,214 / 248,320,底部 HiLog 对应 nanPosition=18、infScale=7、opacity=81、result=PASS。

五、ArkTS 只消费审计快照,不重复计算

原始扫描和浮点判断放在 C++ 层,ArkTS 页面只消费项目适配器返回的审计快照。这样可以避免 UI 每次重组都遍历大量记录,也能让原生层测试独立于页面。跨语言边界只传摘要、诊断样本和状态,不传整份高斯数组。

这段代码解决什么问题。 它把项目自建原生扫描结果转换为 ArkUI 可观察状态,并用 epoch 拒绝页面重建前的旧回调。

type AuditState =
  'ARTIFACT_READY' | 'STREAM_SCANNING' | 'QUARANTINED' |
  'RENDERABLE' | 'BLOCKED'

interface AuditSnapshot {
  taskId: string
  asset: string
  state: AuditState
  total: number
  accepted: number
  quarantined: number
  invalidRate: number
  boundsMeters: string
  result: 'PASS' | 'BLOCK'
}

class AuditCoordinator {
  private epoch: number = 0

  async run(path: string): Promise<AuditSnapshot | undefined> {
    const mine = ++this.epoch
    // splatGuard 是项目自建 Native 模块,不是 HarmonyOS 系统 API。
    const report = await splatGuard.scan(path, 0.001)
    if (mine !== this.epoch) return undefined
    return {
      taskId: 'SPLAT-GUARD-0073',
      asset: 'lamp_scan_g21',
      state: report.pass ? 'RENDERABLE' : 'BLOCKED',
      total: report.total,
      accepted: report.accepted,
      quarantined: report.total - report.accepted,
      invalidRate: report.invalidRate,
      boundsMeters: '1.82 × 0.94 × 1.26 m',
      result: report.pass ? 'PASS' : 'BLOCK'
    }
  }

  invalidate(): void {
    this.epoch += 1
  }
}

epoch 只控制结果提交,不等于取消底层文件读取。真实项目若支持主动取消,应把取消令牌传给原生层,并在读取循环中安全检查。页面退出时先让当前 epoch 失效,再等待或取消原生任务,最后释放文件句柄;注册的回调、Native 引用和临时文件都要成对清理。

状态不会从 STREAM_SCANNING 直接跳成漂亮的成功页。报告先成为 QUARANTINED,页面展示异常分布,门禁通过后才提交 RENDERABLE。如果无效率超过阈值、记录为空或包围盒越界,结果必须是 BLOCKED,渲染按钮保持禁用。

六、运行页先回答“能不能交给渲染器”

RenderabilityAuditPage 的首屏只展示门禁所需事实:任务、资产、扫描进度、通过数、隔离数、无效率、阈值和结论。它不把 106 条隔离记录隐藏在二级日志里,因为“有异常但仍通过”需要被清楚解释。

图中时间是 11:27,电量 68%,扫描进度为 93%。状态箭头显示 STREAM_SCANNING → QUARANTINED → RENDERABLE;任务 SPLAT-GUARD-0073、资产 lamp_scan_g21、248,214 / 248,320 与正文完全一致。红圈标出 0.043% < 0.10%,说明 PASS 来自门禁规则,而不是因为页面没有报错。

进度 93% 与最终统计同时出现,是为了表达演示中的“扫描快照页面”:统计数据来自已完成的固定样本报告,进度用于展示任务回放。真实运行页不能在扫描尚未完成时提前显示最终 PASS;应将回放模式和实时模式明显区分,避免调试图造成错误认知。

七、诊断页要能反推转换器,而不只是列错误

106 条异常按原因分成 18、7、81,能快速判断问题更像字段错位还是范围映射错误。如果所有异常都集中在相邻 sourceOffset,可能是块边界或记录长度计算错误;若透明度越界散落全局,则更像量化、归一化或通道解释错误。

隔离文件不应复制完整原始资产,只保存必要的 offset、原因、少量上下文字段和输入摘要。它既要足够定位,又不能无上限增长。SplatGuard 为每类保留前 16 个样本,其余只累加计数;诊断页可以导出摘要,但默认不把大文件塞进日志。

详情图显示三类异常、包围盒 1.82 × 0.94 × 1.26 m 和最终状态 RENDERABLE。红色箭头从 Inf scale 7 指向“检查转换器尺度通道”,红圈标出透明度 81 条;这张图承担定位作用,与 03 的运行总览明显不同。

八、阈值不是“允许坏数据”的借口

为什么 0.043% 仍可通过?答案取决于产品契约。若隔离后输出流完全不包含坏记录,且异常比例足够低、包围盒合理、固定视角探针正常,那么允许交给预览渲染器可能是合理策略。但用于测量、存档或后续编辑的产物,可能必须要求 0 条异常。

因此阈值需要绑定用途。PREVIEW 可以采用 0.10% 并强制隔离,MEASUREMENT 则要求 0;一旦切换用途,不能沿用同一枚 PASS。阈值还应保存到报告里,避免半年后只看到“通过”却不知道依据是什么。

另外,有限值通过不代表视觉质量好。漂浮噪点、颜色错误、稀疏区域和视角覆盖不足,都需要别的质量指标或人工检查。本文只解决“输入是否会把渲染数学推入非有限或极端范围”,没有声称替代 3DGS 质量评估。

九、测试重点放在传播路径和资源释放

单元测试应构造 NaN position、正负 Infinity scale、-0.1 与 1.1 的 opacity、空文件、半条记录截断和全量异常。每个输入不仅断言计数,还要断言干净输出不含坏记录,包围盒不被隔离项污染,sourceOffset 能回到正确位置。

集成测试则关注生命周期:页面快速进入退出时旧 epoch 不得覆盖新报告;取消后 Reader、CleanWriter、QuarantineWriter 都被关闭;原子替换失败时原文件不受影响;临时文件不会被误当成可渲染产物。C++ 对象所有权要清楚,ArkTS 只持有短生命周期的摘要。

还要验证渲染前最后一道断言:只有 manifest 的状态为 RENDERABLE、摘要 digest 与干净文件一致,加载器才接受。不要让测试页绕过门禁直接打开输出文件,否则最终发布路径与审计路径不是同一条链。

1. 先验证文件结构,再解释浮点内容

非有限值扫描不应成为唯一检查。如果输入文件长度不是记录步长的整数倍,或者头部声明 248,320 条、实际只读到 248,319 条,问题属于结构完整性,不应被统计成一条普通隔离记录。结构失败意味着后续字段边界可能全部错位,继续逐条解释 NaN 类型会产生误导。

SplatGuard 因而把读取分成两层。格式适配器先校验魔数、版本、记录数、字段布局和文件长度;只有这些事实一致,才输出归一化流。扫描器只接收已经明确边界的 SplatRecord。如果结构层失败,状态直接进入 BLOCKED,报告写明 expectedBytes、actualBytes 和首个异常 offset,不生成干净产物。

同理,字节序和浮点精度应在适配器声明。把半精度字段按单精度读取,可能得到大量看似随机但仍有限的数字,这时 std::isfinite 并不会报警。需要用版本化 schema、已知样本向量和字段范围共同发现错位,而不是把有限值检查包装成万能验证。

2. 极端有限值要与非有限值分开统计

3.4e38 是有限 float,却几乎一定不适合作为桌面灯扫描的坐标或尺度。反过来,一个很小的 opacity 可能合法,只是在视觉上贡献极低。把所有异常都归为“不是有限数”会丢失工程判断,因此报告应把数学无效、业务越界和统计离群分成三层。

数学无效包括 NaN 与 Infinity,默认隔离;业务越界包括 opacity 超出 [0,1]、尺寸超过本场景契约,也默认阻断或隔离;统计离群则需要结合分布判断,例如 scale 超过中位数若干倍、位置远离主体簇。第三类不宜直接删除,因为细长结构或少量远端物体也可能真实存在。

对于 lamp_scan_g21,本文只把前两层纳入 PASS 规则。离群检测可以生成提示,但不改变 0.043% 的无效率,也不在没有视觉证据时自动删点。这种分层能避免“门禁越严格越好”的误区:过度清理也会改变重建结果。

3. 隔离产物要与源资产建立可验证关系

干净流与隔离摘要都应保存源资产 digest、适配器版本、阈值版本和生成时间。加载器打开干净流时重新读取 manifest,确认 digest 对得上;如果用户替换了源文件或工具升级后复用了旧报告,状态不能继续显示 RENDERABLE。

临时文件建议使用任务 ID 和随机后缀,写完后先 flush、关闭,再计算摘要并执行原子提交。异常退出留下的临时文件在下次启动时按任务状态清理,不能因为文件名看起来像结果就加入资源列表。对于大文件,摘要计算可与流式扫描同步进行,避免再读一遍;但必须明确摘要覆盖的是原始输入还是干净输出,两者不能共用一个字段。

隔离摘要还要限制敏感内容。空间重建产物可能来自室内场景,诊断系统不应默认上传原始坐标或完整文件。本文页面只显示计数、包围盒和少量 offset;若需要导出详细样本,应由用户主动触发,并遵循项目的数据处理与隐私策略。

4. 渲染探针要验证“没有继续传播”

通过数值门禁后,仍应安排一个最小渲染探针:固定相机、固定背景、固定输出尺寸,渲染少量帧并检查加载器是否报告错误。这个探针不是为了评价画面审美,而是确认清洗后的数据不会在矩阵、排序或着色阶段重新产生非有限值。

渲染探针失败时,应保留数值审计 PASS 与渲染 BLOCK 两个结论,不能回头篡改无效率。这样定位会更快:数据边界已经通过,问题落在 GPU 资源、渲染格式、着色器或相机参数。相反,如果数值门禁已经失败,就没有必要把危险输入继续送入渲染器“看看会怎样”。

对于本文示例,最终 RENDERABLE 只表示通过数值和尺寸门禁,并不声称完成真实 GPU 探针。文章刻意保留这条限制,是为了区分示例演示和已跑通证据。项目真正上线时,应把渲染探针结果作为 manifest 的独立字段,而不是靠一张漂亮截图代替。

5. 阈值变化也需要重新审计

如果团队把预览阈值从 0.10% 放宽到 0.20%,旧报告不能自动获得新的 PASS 含义;如果收紧到 0.02%,原有干净流虽然仍不含那 106 条坏记录,但发布决策已经变化。门禁报告必须记录 policyId 和具体阈值,加载器校验当前策略是否与报告一致。

更稳妥的做法是把“扫描事实”和“发布判断”分开保存。扫描事实包括总数、各类异常、包围盒与摘要,通常不因策略改变而变化;发布判断引用一份策略,对这些事实给出 PASS 或 BLOCK。这样调整阈值时可以重新计算判断,无需再次解析大文件,同时仍能追踪当时依据。

本文的 0.10%、尺寸上限和 PASS 都属于 preview-lamp-v1 的示例策略。换成房间、人物或精密测量任务时,应创建新的策略 ID。把用途写进策略名,比在代码里散落几个浮点常量更容易审核,也更不容易误复用。

十、结论:在渲染前隔离数学异常

SplatGuard 的价值不在于把 106 条坏记录“修没了”,而在于把问题放到可解释的边界:项目适配器负责把官方重建结果转成项目自有的归一化流,C++ 扫描器负责有限值、范围和包围盒,ArkTS 页面负责呈现审计快照与提交状态。每一层都只承诺自己真正做过的事。

演示结果是 248,214 / 248,320 通过、106 条隔离、无效率 0.043%、包围盒 1.82 × 0.94 × 1.26 m,最终进入 RENDERABLE。这些数据仅用于说明流程;真实项目必须基于自己的输出格式、用途和设备验证重新设定阈值。

当重建完成但画面异常时,先问“记录是否有限、范围是否成立、输出是否完整”,往往比继续调整相机更接近根因。把这道门放在渲染器之前,能让黑屏和爆炸色块从不可复现的视觉故障,变成可以计数、隔离和回查的工程问题。

更重要的是,报告必须允许下一位开发者从结果回到输入:知道使用了哪份适配器、哪条策略、哪些记录被隔离,以及渲染器最终接收的究竟是哪一份文件。可追溯性本身就是可渲染性门禁的一部分。

参考资料:

  • HarmonyOS Spatial Recon C 端侧重建流程:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/spatial-recon-c-spatial-recon-pipeline
  • C++ std::isfinite 标准函数说明(实现时以所用编译器标准库为准)
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐