1
This commit is contained in:
+189
-44
@@ -1,21 +1,22 @@
|
||||
@tool
|
||||
extends McpTestSuite
|
||||
## 回归测试:面部那套「UV 孤岛 + 预设 + 画笔」的契约。
|
||||
## 回归测试:脸部那套「UV 矩形 + 预设 + 画笔」的契约。
|
||||
##
|
||||
## 脸是 body_mesh 的 surface 1,UV 是一个孤岛;shader 靠 face_uv_min / face_uv_size 把孤岛
|
||||
## 重映射到 0~1,再拿这个 UV 去采样预设和笔画层。**模型重导后 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 里 face_uv_min / face_uv_size 的默认值,必须和 shader 的 uniform 默认值一致
|
||||
const FACE_UV_MIN := Vector2(0.142, 0.031)
|
||||
const FACE_UV_SIZE := 0.214
|
||||
## 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.003
|
||||
const EPS := 0.004
|
||||
|
||||
|
||||
func suite_name() -> String:
|
||||
@@ -39,6 +40,69 @@ func _body_mesh() -> MeshInstance3D:
|
||||
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:
|
||||
@@ -47,60 +111,114 @@ func test_player_has_face_rig_wired() -> void:
|
||||
assert_true(rig != null, "Player 下缺少 FaceRig 节点")
|
||||
if rig == null:
|
||||
return
|
||||
assert_eq(rig.surface_index, 1, "面部是 surface 1(名字叫 face),索引写错就画到身体上了")
|
||||
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_face_surface_exists() -> void:
|
||||
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 m := mi.mesh as ArrayMesh
|
||||
assert_true(m != null, "body_mesh 的 mesh 不是 ArrayMesh")
|
||||
if m == null:
|
||||
var mesh := mi.mesh as ArrayMesh
|
||||
assert_true(mesh != null, "body_mesh 的 mesh 不是 ArrayMesh")
|
||||
if mesh == null:
|
||||
return
|
||||
assert_gt(m.get_surface_count(), 1, "body_mesh 至少要有两个 surface(0 身体 / 1 脸)")
|
||||
if m.get_surface_count() < 2:
|
||||
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(m.surface_get_name(1), "face", "surface 1 不叫 face 了 —— 确认过再改 surface_index")
|
||||
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_island_matches_shader() -> void:
|
||||
var mi := _body_mesh()
|
||||
if mi == null:
|
||||
## 脸部矩形必须整块落在头部孤岛里,否则表情会画到身体别的部位上。
|
||||
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
|
||||
var m := mi.mesh as ArrayMesh
|
||||
if m == null or m.get_surface_count() < 2:
|
||||
return
|
||||
var uv: PackedVector2Array = m.surface_get_arrays(1)[Mesh.ARRAY_TEX_UV]
|
||||
var mn := Vector2(INF, INF)
|
||||
var mx := Vector2(-INF, -INF)
|
||||
for v in uv:
|
||||
mn = mn.min(v)
|
||||
mx = mx.max(v)
|
||||
var far := FACE_UV_MIN + Vector2(FACE_UV_SIZE, FACE_UV_SIZE)
|
||||
# 孤岛必须整个落在 shader 重映射用的那个方框里,否则脸会错位 / 被裁掉
|
||||
assert_true(mn.x >= FACE_UV_MIN.x - EPS and mn.y >= FACE_UV_MIN.y - EPS,
|
||||
"面部 UV 起点 %s 跑出了 face_uv_min %s —— 模型重导后 UV 变了?" % [mn, FACE_UV_MIN])
|
||||
assert_true(mx.x <= far.x + EPS and mx.y <= far.y + EPS,
|
||||
"面部 UV 终点 %s 超出了 face_uv_min + face_uv_size = %s" % [mx, far])
|
||||
# 允许负边长(负数 = 把该轴翻过来),所以先归一化成 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 sh: Shader = load(SHADER_PATH)
|
||||
assert_true(sh != null, "%s 加载失败" % SHADER_PATH)
|
||||
if sh == null:
|
||||
var file := FileAccess.open(SHADER_PATH, FileAccess.READ)
|
||||
assert_true(file != null, "%s 加载失败" % SHADER_PATH)
|
||||
if file == null:
|
||||
return
|
||||
var names: Array[String] = []
|
||||
for p in sh.get_shader_uniform_list():
|
||||
names.append(str(p.get("name")))
|
||||
for required in ["face_uv_min", "face_uv_size", "base_tex", "paint_tex"]:
|
||||
assert_true(names.has(required), "shader 少了 uniform %s,实际为 %s" % [required, names])
|
||||
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 加载失败")
|
||||
@@ -109,8 +227,25 @@ func test_face_sync_methods_exist() -> void:
|
||||
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"]:
|
||||
assert_true(names.has(required), "face_rig.gd 少了 %s,联机时脸不会同步" % required)
|
||||
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:
|
||||
@@ -129,4 +264,14 @@ func test_presets_are_square() -> void:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user