Files
agame/tests/test_face.gd
T
2026-10-03 18:04:07 +08:00

252 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
## 回归测试:脸部那套「贴花 + 预设 + 画笔」的契约。
##
## 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(尾巴在 -Z 那一侧),写成 -normalize 表情就跑到背上去了
assert_true(not code.contains("-normalize(object_normal).z"),
"正面遮罩写反了:模型正面是本地 +Z,-normalize 会让表情出现在背上")
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)