三维与渲染

flutter_scene 运行时状态边界修复:动态网格缩容、异步模板计数与物理快照同步

记录向 flutter_scene 提交的三处运行时状态一致性修复:可更新网格缩容时的环形缓冲脏区间夹紧、异步场景加载失败后的模板持有计数释放,以及 Rapier 原生快照恢复后的刚体注册表对齐。

约 16 分钟读完
目录
  1. 1. 可更新网格缩容时的环形缓冲脏区间越界(PR #293)
  2. 触发场景与异常原因
  3. 修复方式与边界
  4. 2. 异步场景加载中途异常后的模板持有计数释放(PR #301)
  5. 触发场景与异常原因
  6. 修复方式与边界
  7. 3. 原生物理快照恢复与 Dart 刚体注册表的成员对齐(PR #299)
  8. 触发场景与异常原因
  9. 修复方式与跨平台编码细节
  10. 4. 回归测试与排查小结

在使用和调试 flutter_scene 的过程中,除了着色器与渲染后端适配,还遇到过几处与渲染画面本身无关、但会在特定交互序列下触发异常或状态失步的运行时边界问题。它们分别出现在动态网格上传(bdero/flutter_scene#293)、异步场景模板缓存(bdero/flutter_scene#301)和 Rapier 物理世界快照恢复(bdero/flutter_scene#299)三个模块中。

把这三处修复放在一起记录,是因为它们背后的成因很相似:当同一份运行时状态同时维护在两套结构里(CPU 数组与多槽位 GPU 环形缓冲、预占引用计数与多阶段异步实例化、Dart 句柄表与 FFI 原生对象池)时,常规的单向增长或正常完成路径往往完全正常,一旦发生容量缩减、中途抛出异常或状态回滚,两侧记录的范围与成员就会失去同步。

1. 可更新网格缩容时的环形缓冲脏区间越界(PR #293)

触发场景与异常原因

在 flutter_scene 中,需要按帧更新顶点属性或重建拓扑的程序化网格会将存储模式声明为 GeometryStorage.updatable。为了避免 CPU 覆写顶点数据时与 GPU 尚未执行完的上一帧读取发生竞争,底层通过 _RingBufferStream 为每个顶点属性流维护了深度为 4(_ringDepth = 4)的 hostVisible 设备缓冲环,每次写入前将游标 _cursor 轮转到下一个槽位。

由于槽位是轮流使用的,当前选中的缓冲槽位上一次写入是在 4 次更新之前,期间其他帧发生过的局部修改它都还没有同步。因此 _RingBufferStream 为每个槽位单独记录了一个半开脏区间 _stale[i] = (start, end),每次更新时通过 unionVertexRange 把调用方本次修改的区间并入所有槽位的待上传区间。

在网格顶点数只增不减或保持不变时,这套机制工作正常;但当可更新网格发生缩容(例如测试序列中顶点数按 64 -> 1 -> 64 变化)时,会在上传阶段触发 RangeError:

  1. 初次按 64 个顶点构建网格时,环形缓冲按 2 的幂分配 _capacityVertices = 64,4 个槽位的初始脏区间均设为 (start: 0, end: 64);首帧写完槽位 0 后,槽位 1..3 仍保留 (0, 64)。
  2. 下一次调用 rebuild 将网格缩减为 1 个顶点时,传入的 CPU 字节数组 bytes 长度变为 1 * bytesPerVertex。由于 1 <= _capacityVertices,环形缓冲不会重新分配,而是直接轮转到槽位 1。
  3. 槽位 1 原有的 (0, 64) 与本次变更 (0, 1) 求并集后,得到的待上传区间仍然是 (0, 64)。随后计算上传字节长度 64 * bytesPerVertex 并调用 ByteData.sublistView(bytes, offset, offset + length),就会越过当前只有 1 个顶点的 CPU 数组边界并抛出 RangeError。

修复方式与边界

缩容后超出当前 vertexCount 的旧顶点已经不在本次返回的 BufferView(lengthInBytes: vertexCount * bytesPerVertex) 绘制范围内,无需上传;如果后续某次 rebuild 再次把网格扩大回 64 个顶点,全量更新路径传入的变更区间 (0, 64) 会重新把新增范围标记为待上传。

因此修复方式是在合并脏区间后增加一步 clampVertexRange,将每个槽位的起止端点夹紧到当前快照的 vertexCount 以内:

({int start, int end}) clampVertexRange(
  ({int start, int end}) range,
  int vertexCount,
) => (
  start: range.start > vertexCount ? vertexCount : range.start,
  end: range.end > vertexCount ? vertexCount : range.end,
);

// 在 _RingBufferStream.writeRange 中合并并夹紧脏区间:
final change = (start: dirtyStart, end: dirtyEnd);
for (var i = 0; i < _stale.length; i++) {
  _stale[i] = clampVertexRange(
    unionVertexRange(_stale[i], change),
    vertexCount,
  );
}

对于原本起点就已经超出新 vertexCount 的旧区间(如 (30, 64) 夹紧到 1 后变为 (1, 1)),upload.end > upload.start 为假,当次直接跳过冗余拷贝。

2. 异步场景加载中途异常后的模板持有计数释放(PR #301)

触发场景与异常原因

SceneRegistry.loadScene() 在加载编译后的 .fsceneb 场景资产时,会将解析好的共享模板缓存在 _sceneTemplates 中,并用 _sceneTemplateHolders[key] 记录当前持有该模板的调用方数量;当业务侧不再需要该场景时,调用 releaseScene() 递减计数,归零后驱逐缓存。

为了防止两个调用方并发加载同一场景时,先完成的一方提前调用 releaseScene() 把另一方正在使用的模板驱逐掉,loadScene() 在 await pending 之前就预先把 _sceneTemplateHolders[key] 加了 1。但原先的异常清理范围只包住了第一步模板读取:

  1. 若 await pending 本身失败,旧代码直接调用 _sceneTemplateHolders.remove(key) 清空整个键的计数(若此时已有并发重试替换了条目,还会影响后续持有者);
  2. 更常见的问题在模板读取成功之后:接下来的 realizeSceneAsync(包含自定义 ComponentCodec 解码)、可选的 realizeStage 环境构建以及 HotReloadCoordinator 注册都在 try / catch 之外;
  3. 一旦自定义组件解码或节点实例化抛出异常,loadScene() 向外抛出错误,调用方根本没有拿到返回的 Node 实例,自然不会、也不应该再去配对调用一次补偿性的 releaseScene()。结果就是这次失败调用预先占下的持有计数一直留在缓存里,sceneTemplateCacheCount() 无法归零。

修复方式与边界

修复时保留了 await 前预先加 1 的并发保护语义,将递减与驱逐逻辑提取为统一的私有辅助函数 _releaseSceneTemplateClaim,并把 try / catch 扩展到模板就绪后的全部实例化与注册阶段:

bool _releaseSceneTemplateClaim(String key) {
  final holders = _sceneTemplateHolders[key];
  if (holders == null) return false;
  if (holders > 1) {
    _sceneTemplateHolders[key] = holders - 1;
    return false;
  }
  _sceneTemplateHolders.remove(key);
  return _sceneTemplates.remove(key) != null;
}

在这套计数规则下,三条退出路径保持一致:

  • 模板读取阶段抛出异常:仅在 _sceneTemplates[key] 仍指向当前失败的 pending Future 时移除模板条目,并通过 _releaseSceneTemplateClaim(key) 只退还当前调用自己的 1 份持有计数;
  • 节点实例化、Stage 应用或热重载注册抛出异常:捕获异常后调用 _releaseSceneTemplateClaim(key) 退还当前预占计数,再继续 rethrow;
  • 正常加载后调用 releaseScene():复用同一个 _releaseSceneTemplateClaim(key),最后一个持有者释放时同步清理模板缓存。

需要说明的是,这处修改解决的是异常路径下的模板引用计数对称性,并没有给 loadScene() 增加单次请求的取消句柄(cancellation token);releaseScene(sourcePath) 依然是按资源键递减计数,而非取消某一次正在进行中的加载请求。

3. 原生物理快照恢复与 Dart 刚体注册表的成员对齐(PR #299)

触发场景与异常原因

flutter_scene_rapier 通过 FFI(以及 Web 端的 WASM 绑定)封装了 Rust 编写的 Rapier 3D 物理引擎。在运行时中,物理状态实际上分成了两层:底层 Rust 维护物理世界与刚体对象池,上层 Dart 的 RapierWorld 则维护 _bodies 映射表,将 64 位刚体句柄(handle)关联到场景节点的 PoseTarget 上,以便每次 step() 与 interpolatePoses() 后把求得的位移和旋转写回渲染节点。

此前 RapierWorld.snapshot() 和 restore() 直接透传了底层 _bindings.snapshot() 与 _bindings.restore() 的字节流。原生物理快照能够完整回滚 Rust 侧的刚体集合,但并不会自动回滚 Dart 侧的 _bodies 注册表和外部持有的节点对象。如果在拍下快照之后发生过刚体增删,直接恢复快照就会出现两类状态失步:

  1. 快照之后新建了刚体:恢复快照会把底层物理世界回退到该刚体创建之前,但 Dart 侧 _bodies 仍保留着这个句柄,后续帧继续尝试读取并驱动一个底层已不存在的刚体;
  2. 快照之后销毁了刚体:调用 destroyBody 时,Dart 侧已经移除了 _bodies 记录并释放了关联的 PoseTarget,但恢复旧快照会让该刚体及其碰撞体在底层物理世界中重新出现,变成没有上层持有者、却依然参与碰撞检测与射线查询的残留刚体。

修复方式与跨平台编码细节

在不改动原生预编译二进制库的前提下,flutter_scene_rapier(0.5.0)将 RapierWorld.snapshot() 的返回值改为带头部信封的复合字节流(_WorldSnapshot):在原生快照字节前写入 'FSRS' 魔数(0x53525346)、版本号与排序后的当前 _bodies.keys 刚体句柄表。

这里有一个容易忽略的跨平台细节:Rapier 的刚体句柄是打包的 64 位无符号整数(低 32 位为槽位索引,高 32 位为槽位复用时递增的代数 generation,槽位回收新建后句柄值会超过 0xFFFFFFFF)。而在 Web 端经 dart2js 编译时,Dart 没有原生的 64 位整数类型,按位移位与按位与运算都会被截断到 32 位。因此编码与解码句柄表时不能用 >> 32 或 & 0xFFFFFFFF,需要按算术整除与取模拆成两个小端序 32 位通道存储:

static const _lane = 0x100000000;

// 编码单个 64 位句柄为两个小端 uint32:
data
  ..setUint32(offset, handle % _lane, Endian.little)
  ..setUint32(offset + 4, handle ~/ _lane, Endian.little);

// 解码还原:
final restoredHandle =
    data.getUint32(offset, Endian.little) +
    data.getUint32(offset + 4, Endian.little) * _lane;

调用 restore() 时先校验信封头(拒绝缺少成员信息的旧格式或损坏字节流),在底层 _bindings.restore(decoded.nativeSnapshot) 成功后立即执行 _reconcileBodies 对齐两侧集合:

void _reconcileBodies(Set<int> snapshotHandles) {
  _bodies.removeWhere((handle, _) => !snapshotHandles.contains(handle));
  for (final handle in snapshotHandles) {
    if (!_bodies.containsKey(handle)) _bindings.destroyBody(handle);
  }
}

这样快照后新建的刚体记录会从 _bodies 中移除,不再继续驱动;快照后已被业务层销毁、却被原生快照带回来的刚体则会立即在底层补发一次 destroyBody,不会留下悬空的碰撞体。同样需要明确它的职责边界:_reconcileBodies 保证的是 RapierWorld 内部刚体注册表与原生世界的一致性,并不会替业务层自动重建已销毁的渲染节点;若上层需要完整的关卡对象回滚,仍应由应用层根据场景状态重新创建对应节点与刚体。

4. 回归测试与排查小结

这三处问题都通过独立的小型回归用例固定了下来:

  • 网格缩容边界(mesh_geometry_test.dart):覆盖 64 -> 1 -> 64 顶点缩容与再扩容序列,验证缩容上传不越界、再次扩容后新增顶点重新标记并完成上传;
  • 模板持有计数(scene_registry_test.dart):通过故意抛出异常的自定义 _ThrowingCodec 与延迟完成的 _DeferredSceneBundle,分别验证“实例化失败后缓存计数归零”以及“并发等待期间单方释放不会提前驱逐共享模板”;
  • 物理快照对齐(world_snapshot_test.dart):分别构造快照后新增刚体、快照后销毁带碰撞体刚体(用 overlapSphere 验证恢复后无残留碰撞体)以及槽位回收后超过 32 位上限的句柄往返测试。

回看这类跨层运行时代码,涉及缓冲复用、异步资源缓存和 FFI 状态快照时,单独检查“资源创建”和“常规释放”往往不够;把容量变小、中间阶段失败、状态回退这三条非常规路径补进状态转换检查里,能提前排除不少隐蔽的越界与泄漏问题。