249 lines
11 KiB
GDScript
249 lines
11 KiB
GDScript
@tool
|
||
extends McpTestSuite
|
||
## 回归测试:脸部那套「贴花 + 预设 + 画笔」的契约。
|
||
##
|
||
## body_mesh 只有一个材质 mat_body,脸是 shader 里贴在身体正面上的一块**贴花**:
|
||
## 用模型本地坐标 (x, y) 定位、用法线朝前的程度当遮罩,所以和 UV 孤岛怎么排无关,
|
||
## 也不会因为越界糊到肚子/背上。这里盯住:贴花参数还在、矩形大致落在模型包围盒里
|
||
## (模型换尺度或换原点时会红)、以及 FaceRig 不许再抢着传这些 uniform。
|
||
|
||
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"
|
||
## 模型本地坐标,给点容差
|
||
const EPS := 0.05
|
||
|
||
|
||
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 源码里抓 `名字 = vec2(a, b);` 的两个数(uniform 和 const 都能抓)。
|
||
func _shader_vec2(code: String, name: String) -> Vector2:
|
||
var marker := "%s = vec2(" % name
|
||
var start := code.find(marker)
|
||
assert_true(start >= 0, "shader 里找不到 %s 的 vec2 默认值" % 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
|
||
|
||
|
||
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", "face_front_edge"]:
|
||
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_decal_inside_body_bounds() -> 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
|
||
assert_true(size.x > 0.0 and size.y > 0.0, "face_size 必须是正数(要翻转请用 face_flip_y)")
|
||
var mi := _body_mesh()
|
||
if mi == null:
|
||
return
|
||
var bounds := mi.mesh.get_aabb()
|
||
var rect_min := center - size * 0.5
|
||
var rect_max := center + size * 0.5
|
||
assert_true(rect_min.x >= bounds.position.x - EPS and rect_min.y >= bounds.position.y - EPS,
|
||
"贴花左下角 %s 跑到模型包围盒 %s 外面了" % [rect_min, bounds])
|
||
assert_true(rect_max.x <= bounds.end.x + EPS and rect_max.y <= bounds.end.y + EPS,
|
||
"贴花右上角 %s 跑到模型包围盒 %s 外面了 —— 模型换尺度/原点后会这样" % [rect_max, bounds])
|
||
|
||
|
||
func test_face_shader_has_expected_uniforms() -> void:
|
||
var code := _shader_code()
|
||
if code.is_empty():
|
||
return
|
||
for required in [
|
||
"body_tex", "body_tint", "surface_roughness", "surface_metallic",
|
||
"face_center", "face_size", "face_front_edge", "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("varying vec3 object_position") and code.contains("object_position.xy"),
|
||
"贴花必须用模型本地坐标定位,否则又会被 UV 孤岛边界卡住")
|
||
assert_true(code.contains("-normalize(object_normal).z"),
|
||
"贴花必须按「法线朝前」筛一遍,而且正面是模型本地 -Z,写反了表情会跑到背上")
|
||
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)
|