Files
agame/tests/test_face.gd
T
2026-10-02 15:42:34 +08:00

278 lines
11 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@tool
extends McpTestSuite
## 回归测试:脸部那套「UV 矩形 + 预设 + 画笔」的契约。
##
## body_mesh 现在只有一个材质 mat_body,脸部不再单独换材质,而是靠 face_uv_min / face_uv_size
## 在这块 UV 上圈出脸部矩形。**模型重导或改 UV 之后,脸就会画歪、被裁掉、甚至画到别的部位上,
## 而且不报任何错** —— 所以这里把数值钉死,改模型时先让测试红一次。
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"
## FaceRig / shader 里脸部矩形的默认值;必须和 shader 的 uniform 默认值一致
const FACE_UV_MIN := Vector2(0.695, 0.682)
const FACE_UV_SIZE := Vector2(0.301, 0.314)
## UV 是浮点,两边各写一份,给点容差
const EPS := 0.004
func suite_name() -> String:
return "face"
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)
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_uv_rect_inside_head_island() -> void:
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
# 允许负边长(负数 = 把该轴翻过来),所以先归一化成 min/max
var rect_min := Vector2(minf(FACE_UV_MIN.x, FACE_UV_MIN.x + FACE_UV_SIZE.x),
minf(FACE_UV_MIN.y, FACE_UV_MIN.y + FACE_UV_SIZE.y))
var rect_max := Vector2(maxf(FACE_UV_MIN.x, FACE_UV_MIN.x + FACE_UV_SIZE.x),
maxf(FACE_UV_MIN.y, FACE_UV_MIN.y + FACE_UV_SIZE.y))
assert_true(rect_min.x >= head.position.x - EPS and rect_min.y >= head.position.y - EPS,
"脸部矩形起点 %s 跑出了头部 UV 孤岛 %s" % [rect_min, head])
assert_true(rect_max.x <= head.end.x + EPS and rect_max.y <= head.end.y + EPS,
"脸部矩形终点 %s 超出了头部 UV 孤岛 %s —— 模型改过 UV?" % [rect_max, head])
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_uv_min", "face_uv_size", "face_uv_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)