@tool extends McpTestSuite ## 回归测试:脸部那套「UV 矩形 + 预设 + 画笔」的契约。 ## ## body_mesh 只有一个材质 mat_body,脸部不再单独换材质,而是由 shader 顶部的 ## face_center / face_size 在本体 UV 上圈出表情矩形。**模型重导或改 UV 之后,脸就会画歪、 ## 被裁掉、甚至糊到肚子上,而且不报任何错** —— 所以这里把「矩形必须落在头部孤岛里」钉死, ## 改模型时先让测试红一次。数值本身从 shader 源码里读,方便随手调。 const PLAYER_SCENE_PATH := "res://character/player/player.tscn" const SHADER_PATH := "res://character/player/face/face_paint.gdshader" const PRESET_DIR := "res://character/player/face/presets" const BODY_MESH_PATH := "Body/mouse/MouseRig/Skeleton3D/body_mesh" const BODY_MATERIAL_NAME := "mat_body" ## UV 是浮点,给点容差 const EPS := 0.004 func suite_name() -> String: return "face" func _shader_code() -> String: var file := FileAccess.open(SHADER_PATH, FileAccess.READ) assert_true(file != null, "%s 加载失败" % SHADER_PATH) if file == null: return "" return file.get_as_text() ## 从 shader 源码里抓 `uniform vec2 名字 = vec2(a, b);` 的两个数。 func _shader_vec2(code: String, name: String) -> Vector2: var marker := "uniform vec2 %s = vec2(" % name var start := code.find(marker) assert_true(start >= 0, "shader 里找不到 uniform vec2 %s 的默认值" % name) if start < 0: return Vector2(INF, INF) start += marker.length() var stop := code.find(")", start) if stop < 0: return Vector2(INF, INF) var parts := code.substr(start, stop - start).split(",") if parts.size() != 2: return Vector2(INF, INF) return Vector2(float(parts[0].strip_edges()), float(parts[1].strip_edges())) func _player() -> Node: var packed := load(PLAYER_SCENE_PATH) as PackedScene assert_true(packed != null, "%s 未能加载" % PLAYER_SCENE_PATH) if packed == null: return null var player := packed.instantiate() track(player) return player func _body_mesh() -> MeshInstance3D: var player := _player() if player == null: return null return player.get_node_or_null(BODY_MESH_PATH) as MeshInstance3D ## 找「最高处那块 UV 孤岛」(= 头部)的 UV 范围。脸部矩形必须落在它里面。 func _head_island_uv_rect() -> Rect2: var mi := _body_mesh() if mi == null: return Rect2() var mesh := mi.mesh as ArrayMesh if mesh == null or mesh.get_surface_count() == 0: return Rect2() var arrays := mesh.surface_get_arrays(0) var uvs := arrays[Mesh.ARRAY_TEX_UV] as PackedVector2Array var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array var indices := PackedInt32Array() if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array: indices = arrays[Mesh.ARRAY_INDEX] if uvs.size() == 0 or indices.size() == 0: return Rect2() var parent := [] parent.resize(uvs.size()) for i in uvs.size(): parent[i] = i for t in indices.size() / 3: _union(parent, indices[t * 3], indices[t * 3 + 1]) _union(parent, indices[t * 3], indices[t * 3 + 2]) var groups := {} for i in uvs.size(): var root := _find(parent, i) if not groups.has(root): groups[root] = [] groups[root].append(i) var best_root := -1 var best_top := -INF for root in groups: var members: Array = groups[root] var top := -INF for i in members: top = maxf(top, verts[i].y) if top > best_top: best_top = top best_root = root var members: Array = groups.get(best_root, []) var uv_min := Vector2(INF, INF) var uv_max := Vector2(-INF, -INF) for i in members: var uv: Vector2 = uvs[i] uv_min = Vector2(minf(uv_min.x, uv.x), minf(uv_min.y, uv.y)) uv_max = Vector2(maxf(uv_max.x, uv.x), maxf(uv_max.y, uv.y)) return Rect2(uv_min, uv_max - uv_min) func _find(parent: Array, index: int) -> int: while parent[index] != index: parent[index] = parent[parent[index]] index = parent[index] return index func _union(parent: Array, a: int, b: int) -> void: var root_a := _find(parent, a) var root_b := _find(parent, b) if root_a != root_b: parent[root_b] = root_a func test_player_has_face_rig_wired() -> void: var player := _player() if player == null: return var rig := player.get_node_or_null("FaceRig") as FaceRig assert_true(rig != null, "Player 下缺少 FaceRig 节点") if rig == null: return assert_eq(rig.surface_index, 0, "body_mesh 只剩一个材质,FaceRig 必须覆盖 surface 0") assert_true(rig.get_node_or_null(rig.mesh_path) is MeshInstance3D, "FaceRig.mesh_path(%s)解析不到 MeshInstance3D —— 换模型后路径要跟着改" % rig.mesh_path) # 表情位置/大小只写在 shader 里;脚本一旦也传这三个 uniform,就会把调好的值盖掉 var script_text := FileAccess.open("res://character/player/face/face_rig.gd", FileAccess.READ).get_as_text() for name in ["face_center", "face_size", "face_flip_y"]: assert_true(not script_text.contains("set_shader_parameter(\"%s\"" % name), "face_rig.gd 又在传 %s 了 —— shader 顶部调好的值会被脚本覆盖" % name) func test_body_mesh_has_single_material() -> void: var mi := _body_mesh() assert_true(mi != null, "找不到 %s" % BODY_MESH_PATH) if mi == null: return var mesh := mi.mesh as ArrayMesh assert_true(mesh != null, "body_mesh 的 mesh 不是 ArrayMesh") if mesh == null: return assert_eq(mesh.get_surface_count(), 1, "body_mesh 应该只有一个材质槽了") var material := mesh.surface_get_material(0) assert_true(material != null, "surface 0 没有材质") if material == null: return assert_eq(material.resource_name, BODY_MATERIAL_NAME, "body_mesh 的材质不再是 %s,FaceRig 抄身体参数会失效" % BODY_MATERIAL_NAME) var standard := material as StandardMaterial3D assert_true(standard != null, "%s 不是 StandardMaterial3D" % BODY_MATERIAL_NAME) if standard != null: assert_true(standard.albedo_texture != null, "%s 没有 BaseColor 贴图,身体底色会变成白" % BODY_MATERIAL_NAME) ## 脸部矩形必须整块落在头部孤岛里,否则表情会画到身体别的部位上。 ## 表情矩形(中心 ± 半个大小)必须整块落在头部孤岛里,否则表情会糊到别的部位上。 func test_face_rect_inside_head_island() -> void: var code := _shader_code() if code.is_empty(): return var center := _shader_vec2(code, "face_center") var size := _shader_vec2(code, "face_size") if not center.is_finite() or not size.is_finite(): return var head := _head_island_uv_rect() assert_true(head.size.x > 0.0 and head.size.y > 0.0, "没算出头部 UV 孤岛 —— 模型结构变了?") if head.size.x <= 0.0: return var rect_min := center - size * 0.5 var rect_max := center + size * 0.5 assert_true(rect_min.x >= head.position.x - EPS and rect_min.y >= head.position.y - EPS, "表情矩形左下角 %s 跑出了头部 UV 孤岛 %s(中心 %s 大小 %s)" % [rect_min, head, center, size]) assert_true(rect_max.x <= head.end.x + EPS and rect_max.y <= head.end.y + EPS, "表情矩形右上角 %s 超出了头部 UV 孤岛 %s(中心 %s 大小 %s)—— 模型改过 UV 还是值调过头了?" % [rect_max, head, center, size]) func test_face_shader_has_expected_uniforms() -> void: var file := FileAccess.open(SHADER_PATH, FileAccess.READ) assert_true(file != null, "%s 加载失败" % SHADER_PATH) if file == null: return var code := file.get_as_text() for required in [ "body_tex", "body_tint", "surface_roughness", "surface_metallic", "face_center", "face_size", "face_flip_y", "base_tex", "base_uv_min", "base_uv_size", "paint_tex", ]: assert_true(code.contains("uniform ") and code.contains(required), "shader 少了 uniform %s" % required) assert_true(code.contains("texture(body_tex, UV)"), "shader 必须用 body_tex 采样全身底色,否则脸和身体不是同一块皮肤") assert_true(code.contains("ROUGHNESS = surface_roughness"), "shader 必须复用 mat_body 的 roughness") assert_true(code.contains("METALLIC = surface_metallic"), "shader 必须复用 mat_body 的 metallic") # alpha 只代表墨线覆盖率,不能写进空间 ALPHA,否则身体面片会被抠出洞 assert_true(not code.contains("ALPHA ="), "面部 shader 写了 ALPHA,会把面片渲染成镂空") func test_face_shader_copies_body_pbr_values() -> void: var mesh_node := _body_mesh() if mesh_node == null: return var body_material := mesh_node.mesh.surface_get_material(0) as StandardMaterial3D assert_true(body_material != null, "surface 0 应有 StandardMaterial3D") if body_material == null: return assert_true(absf(body_material.roughness - 0.65) < 0.001, "mat_body roughness 改变:当前 %f;FaceRig 会动态读取它,测试值跟着改" % body_material.roughness) assert_eq(body_material.metallic, 0.0, "mat_body metallic 改变;FaceRig 会动态读取它") ## 内置表情用的是项目里的透明图集副本(原始表情图已随模型一起删掉)。 func test_builtin_atlas_is_transparent_ink() -> void: var texture: Texture2D = load(FaceRig.ATLAS_PATH) assert_true(texture != null, "%s 加载失败" % FaceRig.ATLAS_PATH) if texture == null: return var image := texture.get_image() var transparent := false var visible_ink := false for y in range(0, image.get_height(), 8): for x in range(0, image.get_width(), 8): var a := image.get_pixel(x, y).a transparent = transparent or a < 0.05 visible_ink = visible_ink or a > 0.5 assert_true(transparent, "内置表情图集没有透明背景,可能忘了去蓝底") assert_true(visible_ink, "内置表情图集里没有可见笔画") func test_builtin_face_uv_tiles_exist() -> void: assert_eq(FaceRig.BUILTIN_EXPRESSIONS.size(), 2, "内置表情清单应包含两张 atlas 表情") if FaceRig.BUILTIN_EXPRESSIONS.size() < 2: return assert_eq(FaceRig.BUILTIN_EXPRESSIONS[0]["id"], "builtin:face_01", "第一个内置表情 ID 改变") assert_eq(FaceRig.BUILTIN_EXPRESSIONS[1]["id"], "builtin:face_02", "第二个内置表情 ID 改变") for entry in FaceRig.BUILTIN_EXPRESSIONS: var rect: Rect2 = entry["rect"] assert_true(rect.position.x >= 0.0 and rect.position.y >= 0.0, "表情图集 rect 起点越界") assert_true(rect.end.x <= 1.0 and rect.end.y <= 1.0, "表情图集 rect 终点越界") ## 联机同步靠这几个方法名,改名了要一起改 —— 改了就不同步,而且不报错 func test_face_sync_methods_exist() -> void: var script := load("res://character/player/face/face_rig.gd") as GDScript assert_true(script != null, "face_rig.gd 加载失败") if script == null: return var names: Array[String] = [] for m in script.get_script_method_list(): names.append(str(m.get("name"))) for required in [ "_pull_remote_face", "_rpc_request_face", "_rpc_send_face", "get_expression_catalog", "select_expression", "save_drawing_as_expression", ]: assert_true(names.has(required), "face_rig.gd 少了 %s,表情列表/保存/联机可能失效" % required) ## 「没有底图」用运行时创建的 RGBA ImageTexture,而不是磁盘上的全透明 PNG。 ## Godot 导入器会把整张同 alpha 的 PNG 优化成 RGB,丢掉 alpha;运行时的 Image 则保留。 func test_runtime_blank_texture_preserves_alpha() -> void: var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) var texture := ImageTexture.create_from_image(image) assert_true(texture != null, "运行时空白 ImageTexture 创建失败") if texture == null: return var loaded := texture.get_image() assert_eq(loaded.get_format(), Image.FORMAT_RGBA8, "空白贴图被降成了 RGB 格式") assert_eq(loaded.get_pixel(0, 0).a, 0.0, "空白贴图 alpha 应为 0") assert_eq(loaded.get_pixel(1, 1).a, 0.0, "空白贴图中心 alpha 应为 0") func test_presets_are_square() -> void: var dir := DirAccess.open(PRESET_DIR) assert_true(dir != null, "打不开预设目录 %s" % PRESET_DIR) if dir == null: return var found := 0 for f in dir.get_files(): if not f.ends_with(".png"): continue found += 1 var tex: Texture2D = load(PRESET_DIR + "/" + f) assert_true(tex != null, "预设 %s 加载失败" % f) if tex == null: continue var s := tex.get_size() assert_eq(int(s.x), int(s.y), "预设 %s 不是正方形(%s),贴到脸上会拉伸" % [f, s]) var image := tex.get_image() var transparent := false var visible_ink := false for y in range(0, image.get_height(), 4): for x in range(0, image.get_width(), 4): var a := image.get_pixel(x, y).a transparent = transparent or a < 0.05 visible_ink = visible_ink or a > 0.5 assert_true(transparent, "预设 %s 没有透明背景,可能忘了去蓝底" % f) assert_true(visible_ink, "预设 %s 没有可见笔画" % f) assert_gt(found, 0, "%s 里一张预设都没有" % PRESET_DIR)