fix(scene): 包围盒沿树累乘变换(修掉入树前的 ERROR 与范围偏位)+ 21 项"真机等价"测试

缺陷:SceneRuntime._compute_aabb() 用 MeshInstance3D.global_transform 计算包围盒,
而它在 _assemble() 里调用 —— 那时运行时节点**还没入树**。两个后果:
- Godot 打一行 `ERROR: Condition "!is_inside_tree()" is true. Returning: Transform3D()`
  (真机每次载入场景都刷一条,与"日志里不应有 ERROR"的排查约定冲突);
- 树外的 global_transform 返回单位变换,算出来的只是各网格的**局部** AABB,
  于是重力/风 Area3D 的碰撞盒与兜底地面会偏小或偏位 —— 对把网格摆在非原点位置的
  场景(导出 GLB 的常见情况)**风力可能作用不到物体上**。

修复:改为从根往下累乘 `Node3D.transform`,不依赖是否入树;挂在单位变换父节点下时
结果与旧语义一致。

新增 21 项无头"真机等价"测试(不需要设备):
- tests/test_scene_config.gd:在代码里手写合法 GLB(三角形 + BIN chunk)落盘后
  **真实加载**:warnings 为空、按 colliders 三种取值分别核对碰撞体(mesh / auto 命中
  *_col / auto 未命中的兜底地面 / none)、重力 Area3D 的 gravity 与方向、
  SPACE_OVERRIDE_REPLACE、风力与 WIND_FORCE_SCALE 对齐,以及**带位移网格**
  (translation [10,0,0])时重力区域与兜底地面都跟着位移(该缺陷的回归测试);
- tests/test_pose_server.gd:**时间维度**验证保活 —— 只发应用层 ping、不发关键点帧的
  客户端在 17 秒(> 15 秒静默超时)后仍保持连接("画面里没人就掉线、疯狂重连"的根因
  对照),而完全不发数据的客户端被 close(1001) 断开。

新增 build/check-version.sh:强制 project.godot 的 application/config/version
(「关于」清单与 .pck 的来源)与 project.yaml、发布版本号三者一致;已接入
build/package-release.sh(打包前)与 build/verify.sh(第 0/4 步),并做过反向验证。
此前没有任何机制保证这两个文件同步 —— 界面显示的版本可能和产物名不一致。

版本升至 0.2.1(project.godot / project.yaml / README / 产物名 / tag 全部一致),
About 清单与 .pck 内版本号均为 0.2.1(已用 strings 核对)。

验证:build/verify.sh 四步全通过(版本一致性 → 编译 → 751 项断言 → 180 帧启动无错误);
产物 opendance-windows-v0.2.1.zip(LZMA,26,627,191 B)
sha256 d49885327047f5a52de6c732431ea080cd1a030ae1c581a8bc0ec0d99bfb792c。
This commit is contained in:
2026-09-17 03:59:29 +08:00
parent 86e5decd10
commit 14ee5f7ec9
11 changed files with 456 additions and 22 deletions
+190
View File
@@ -243,3 +243,193 @@ func test_environment_mapping_clamps_godot_fog_density() -> void:
is_false(env_off.fog_enabled, "fogDensity = 0 时关闭雾")
runtime.free()
runtime_off.free()
# ---------------- 真实 GLB 的场景加载(§5.6 验收:GLB 正常加载 + 参数生效) ----------------
#
# 现有用例覆盖的是参数解析与降级路径,但**没有一条真正加载 GLB**。
# 这里在代码里手写一个合法的 GLB(一个三角形:POSITION + indices,带 BIN chunk),
# 落盘后用 `SceneRuntime.load_from_file()` 真加载,核对:
# * 不再回退内置空场景(warnings 为空、兜底地面未启用);
# * 碰撞体按 `colliders` 三种取值分别正确生成;
# * 重力/风 Area3D 按服务端参数生效;
# * 包围盒**沿树累乘变换**(用带位移的节点验证 —— 曾经这里用 global_transform
# 在入树前计算,会打 ERROR 日志且算出的范围偏小/偏位)。
const TEST_GLB_PATH := "user://test-scene-{0}.glb"
## 手写合法 GLB:一个三角形(POSITION 3×VEC3 + indices 3×u16),节点可命名/可位移
static func _build_triangle_glb(node_name: String, translation: Vector3 = Vector3.ZERO) -> PackedByteArray:
var sp := StreamPeerBuffer.new()
sp.big_endian = false
for v in [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]:
sp.put_float(v)
while sp.get_position() % 4 != 0:
sp.put_u8(0)
var index_offset := sp.get_position()
for i in [0, 1, 2]:
sp.put_u16(i)
while sp.get_position() % 4 != 0:
sp.put_u8(0)
var bin := sp.data_array
var node := {"mesh": 0, "name": node_name}
if translation != Vector3.ZERO:
node["translation"] = [translation.x, translation.y, translation.z]
var gltf := {
"asset": {"version": "2.0", "generator": "OpenDance test"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [node],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0}, "indices": 1}]}],
"buffers": [{"byteLength": bin.size()}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36, "target": 34962},
{"buffer": 0, "byteOffset": index_offset, "byteLength": 6, "target": 34963},
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3", "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"},
],
}
var json_bytes := JSON.stringify(gltf).to_utf8_buffer()
while json_bytes.size() % 4 != 0:
json_bytes.append(0x20)
var out := PackedByteArray()
out.append_array(PackedByteArray([0x67, 0x6C, 0x54, 0x46])) # 'glTF'
out.resize(12)
out.encode_u32(4, 2)
var json_header := PackedByteArray()
json_header.resize(8)
json_header.encode_u32(0, json_bytes.size())
json_header.encode_u32(4, 0x4E4F534A) # 'JSON'
var bin_header := PackedByteArray()
bin_header.resize(8)
bin_header.encode_u32(0, bin.size())
bin_header.encode_u32(4, 0x004E4942) # 'BIN\0'
out.append_array(json_header)
out.append_array(json_bytes)
out.append_array(bin_header)
out.append_array(bin)
out.encode_u32(8, out.size())
return out
func _write_scene_glb(tag: String, node_name: String, translation: Vector3 = Vector3.ZERO) -> String:
var path := TEST_GLB_PATH.format(tag)
var file := FileAccess.open(path, FileAccess.WRITE)
file.store_buffer(_build_triangle_glb(node_name, translation))
file.close()
return path
func _scene_cfg(overrides: Dictionary) -> CatalogTypes.SceneConfig:
var base := {"physics": {"gravity": {"y": -3.5}, "wind": {"base": {"x": 2.0, "y": 0.0, "z": 0.0}}}}.duplicate(true)
for key in overrides:
base["physics"][key] = overrides[key]
return CatalogTypes.SceneConfig.from_dict(base)
## 取重力区域的碰撞盒(用来核对包围盒是否算对)
func _area_box(rt: SceneRuntime, area_name: String) -> CollisionShape3D:
var area := rt.get_node_or_null(area_name) as Area3D
if area == null:
return null
for child in area.get_children():
if child is CollisionShape3D:
return child as CollisionShape3D
return null
func test_real_glb_loads_without_fallback_and_colliders_match_mode() -> void:
var path := _write_scene_glb("mesh", "Floor")
# mesh 模式:所有网格都生成碰撞体
var rt_mesh: SceneRuntime = SceneRuntime.load_from_file(path, _scene_cfg({"colliders": "mesh"}), "真实场景")
eq(rt_mesh.warnings.size(), 0, "真实 GLB 加载无降级告警(warnings 为空)")
not_null(rt_mesh.root, "场景根节点已生成")
eq(rt_mesh.collider_count, 1, "mesh 模式为 1 个网格生成 1 个碰撞体")
is_false(rt_mesh.used_fallback_ground, "有网格时不再生成兜底地面")
contains(rt_mesh.describe(), "碰撞体 1 个", "摘要报告碰撞体数量")
# auto 模式:只认命名约定(Floor_col 命中,Floor 不命中)
var rt_auto_hit: SceneRuntime = SceneRuntime.load_from_file(
_write_scene_glb("auto-hit", "Floor_col"), _scene_cfg({"colliders": "auto"}), "命名命中"
)
eq(rt_auto_hit.collider_count, 1, "auto 模式按 *_col 命名约定生成碰撞体")
var rt_auto_miss: SceneRuntime = SceneRuntime.load_from_file(
_write_scene_glb("auto-miss", "Floor"), _scene_cfg({"colliders": "auto"}), "命名未命中"
)
eq(rt_auto_miss.collider_count, 1, "auto 未命中时为兜底地面生成 1 个碰撞体")
is_true(rt_auto_miss.used_fallback_ground, "auto 未命中时标记使用了兜底地面")
# none 模式:不生成碰撞体,也不兜底
var rt_none: SceneRuntime = SceneRuntime.load_from_file(path, _scene_cfg({"colliders": "none"}), "无碰撞")
eq(rt_none.collider_count, 0, "none 模式不生成任何碰撞体")
is_false(rt_none.used_fallback_ground, "none 模式不兜底")
rt_mesh.free()
rt_auto_hit.free()
rt_auto_miss.free()
rt_none.free()
func test_real_glb_gravity_and_wind_match_config() -> void:
var rt: SceneRuntime = SceneRuntime.load_from_file(
_write_scene_glb("gravity", "Floor"), _scene_cfg({"colliders": "mesh"}), "物理参数"
)
var gravity_area := rt.get_node_or_null("SceneGravityArea") as Area3D
not_null(gravity_area, "重力的 Area3D 已创建")
if gravity_area != null:
almost(gravity_area.gravity, 3.5, 1e-4, "重力大小按服务端配置生效")
almost(gravity_area.gravity_direction.y, -1.0, 1e-5, "重力方向向下")
eq(gravity_area.gravity_space_override, Area3D.SPACE_OVERRIDE_REPLACE,
"场景级重力用 REPLACE 覆盖(不改全局 ProjectSettings)")
not_null(rt.get_node_or_null("SceneWindArea") as Area3D, "风的 Area3D 已创建")
almost(rt.wind_force_at(0.0).x, 2.0 * SceneRuntime.WIND_FORCE_SCALE, 1e-3,
"风力按服务端基础风向量生效")
contains(rt.describe(), "重力 3.5", "日志摘要含重力数值(可核对)")
contains(rt.describe(), "2.0,0.0,0.0", "日志摘要含基础风向量")
rt.free()
func test_aabb_accumulates_transforms_for_offset_meshes() -> void:
# 网格整体位移到 x=10:包围盒必须跟着走,否则重力/风区域会算偏位(风力作用不到)
# 用 mesh 模式:只验证"包围盒是否跟着位移走",不引入 auto 未命中导致的兜底告警
var rt: SceneRuntime = SceneRuntime.load_from_file(
_write_scene_glb("offset", "Floor", Vector3(10, 0, 0)),
_scene_cfg({"colliders": "mesh"}),
"位移场景",
)
eq(rt.warnings.size(), 0, "带位移的真实 GLB 加载无降级告警")
eq(rt.collider_count, 1, "位移场景按 mesh 模式生成 1 个碰撞体")
var box := _area_box(rt, "SceneGravityArea")
not_null(box, "重力区域的碰撞盒存在")
if box != null:
ok(box.position.x > 9.5 and box.position.x < 11.5,
"包围盒沿树累乘变换:区域中心跟着网格位移(x≈10)", "实际 x=%.2f" % box.position.x)
var box_shape := box.shape as BoxShape3D
not_null(box_shape, "碰撞盒是 BoxShape3D")
if box_shape != null:
ok(box_shape.size.x > 10.0, "区域水平尺寸覆盖到位移后的场景", "size.x=%.1f" % box_shape.size.x)
# auto 未命中 → 兜底地面也应落在位移后的位置
var rt_fallback: SceneRuntime = SceneRuntime.load_from_file(
_write_scene_glb("offset-plain", "FloorPlain", Vector3(10, 0, 0)),
_scene_cfg({"colliders": "auto"}),
"位移兜底",
)
var ground := rt_fallback.get_node_or_null("FallbackGround") as StaticBody3D
not_null(ground, "兜底地面已生成")
if ground != null:
ok(ground.position.x > 9.5 and ground.position.x < 11.5,
"兜底地面跟着包围盒位移(x≈10)", "实际 x=%.2f" % ground.position.x)
rt.free()
rt_fallback.free()