修复玩家脸部

This commit is contained in:
2026-10-03 17:26:42 +08:00
parent 974ca67c41
commit bc90e6d7b2
9 changed files with 190 additions and 121 deletions
+29 -92
View File
@@ -1,20 +1,19 @@
@tool
extends McpTestSuite
## 回归测试:脸部那套「UV 矩形 + 预设 + 画笔」的契约。
## 回归测试:脸部那套「贴花 + 预设 + 画笔」的契约。
##
## body_mesh 只有一个材质 mat_body,脸部不再单独换材质,而是由 shader 顶部的
## face_center / face_size 在本体 UV 上圈出表情矩形。**模型重导或改 UV 之后,脸就会画歪、
## 被裁掉、甚至糊到肚子上,而且不报任何错** —— 所以这里把「矩形必须落在头部孤岛里」钉死,
## 改模型时先让测试红一次。数值本身从 shader 源码里读,方便随手调。
## 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"
## UV 是浮点,给点容差
const EPS := 0.004
## 模型本地坐标,给点容差
const EPS := 0.05
func suite_name() -> String:
@@ -29,11 +28,11 @@ func _shader_code() -> String:
return file.get_as_text()
## 从 shader 源码里抓 `uniform vec2 名字 = vec2(a, b);` 的两个数。
## 从 shader 源码里抓 `名字 = vec2(a, b);` 的两个数(uniform 和 const 都能抓)。
func _shader_vec2(code: String, name: String) -> Vector2:
var marker := "uniform vec2 %s = vec2(" % name
var marker := "%s = vec2(" % name
var start := code.find(marker)
assert_true(start >= 0, "shader 里找不到 uniform vec2 %s 的默认值" % name)
assert_true(start >= 0, "shader 里找不到 %s 的 vec2 默认值" % name)
if start < 0:
return Vector2(INF, INF)
start += marker.length()
@@ -63,69 +62,6 @@ 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:
@@ -137,9 +73,9 @@ func test_player_has_face_rig_wired() -> void:
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,就会把调好的值盖掉
# 贴花参数只写在 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"]:
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)
@@ -166,9 +102,8 @@ func test_body_mesh_has_single_material() -> void:
assert_true(standard.albedo_texture != null, "%s 没有 BaseColor 贴图,身体底色会变成白" % BODY_MATERIAL_NAME)
## 脸部矩形必须整块落在头部孤岛里,否则表情会画到身体别的部位上。
## 表情矩形(中心 ± 半个大小)必须整块落在头部孤岛里,否则表情会糊到别的部位上。
func test_face_rect_inside_head_island() -> void:
## 贴花矩形是模型本地坐标,必须落在模型自己的包围盒里,否则表情会飘到体外。
func test_face_decal_inside_body_bounds() -> void:
var code := _shader_code()
if code.is_empty():
return
@@ -176,33 +111,35 @@ func test_face_rect_inside_head_island() -> void:
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:
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 >= 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])
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 file := FileAccess.open(SHADER_PATH, FileAccess.READ)
assert_true(file != null, "%s 加载失败" % SHADER_PATH)
if file == null:
var code := _shader_code()
if code.is_empty():
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",
"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,否则身体面片会被抠出洞