This commit is contained in:
2026-10-03 05:40:26 +08:00
parent faf799837f
commit 7bcbcc69ea
7 changed files with 96 additions and 44 deletions
+16
View File
@@ -296,6 +296,22 @@ FaceRig 加同名 `@export`(默认 `true`),并把 uniform 推给 shader。
`test_run` 32/32。
### 改成「shader 顶部调参」而不是 UI 滑块
用户先问能不能在角色选择界面加滑块移动脸部,我给完方案后他决定不做界面,改成在 `face_paint.gdshader`
顶部放几个简单明了的设置。于是把 `face_uv_min` / `face_uv_size` 换成更直观的 **`face_center`(中心)+ `face_size`(宽高)**,
和 `face_flip_y` 一起集中到 shader 顶部的「调表情就改这一段」,每行都写了「往下挪就减小 .y」这类人话注释。
关键点:**FaceRig 必须停止传这三个 uniform**,否则脚本里的值会把 shader 里调好的值盖掉(等于白改)。
删掉了 `face_uv_min` / `face_uv_size` / `face_uv_flip_y` 三个 @export 和三行 set_shader_parameter,
并在 `test_player_has_face_rig_wired` 里加了一条断言:`face_rig.gd` 源码里不许再出现
`set_shader_parameter("face_center"/"face_size"/"face_flip_y"`。
测试改成从 shader 源码里解析 `face_center` / `face_size`(`_shader_vec2()` 辅助函数),
再判定「中心 ± 半径」是否落在头部孤岛内 —— 这样用户随手调值不会误报,只有溢出孤岛才红。
默认值就是原来的覆盖范围(中心 0.8455/0.839,大小 0.301/0.314),画面不变。
`test_run` 32/32,角色选择场景实机跑无 shader/脚本报错。
## 执行用户提供的 export_split.py 命令
用户给出 Blender 5.1 与 `_rig_tools/export_split.py` 路径。我检查到脚本读取 `bpy.data.objects["MouseRig"]`,因此命令原样缺少 blend 源文件;实际运行时补传已有主工程 `mouse.blend`,其余仍是指定脚本。
+5 -2
View File
@@ -57,8 +57,11 @@
- 表情遮罩用 `step()` 相乘而不是 `if`:采样留在统一控制流里,免得 mipmap 导数在分支里未定义。
- **仍然不能写空间 `ALPHA`**:alpha 只代表墨线覆盖率,写进 ALPHA 会让面片变洞。
- `face_atlas_transparent.png` 是我们自己的素材(从已删除的 `mouse_表情测试.png` 去蓝底、反混合出来的 1024² 副本),保留原图坐标排布;内置 ID `builtin:face_01/02` 对应各自的图集 rect。**原始表情图已随模型删除,这个副本现在是唯一来源,别删。**
- **脸部矩形 = 整块头部 UV 孤岛 (0.695, 0.682) 尺寸 (0.301, 0.314)** —— 用户确认「就是整块头部」。**头部 UV 的 v 越大越靠上,而贴图第 0 行在上边,所以必须开 `face_uv_flip_y`(默认 true),否则表情上下颠倒**(用户实测确认过)。想往下挪:把 `face_uv_min.y` 钉在孤岛底边 0.682、改小 `face_uv_size.y`;**min.y 不能低于 0.682**,否则矩形会伸进下方那段孤岛(u 0.695~0.935 / v 0.293~0.674,是胸腹),表情会同时画到肚子和前胸上。
- `face_uv_min` / `face_uv_size` / `face_uv_flip_y` 在 `player.tscn` 和 `character_select.tscn` 里**各有一份**(两个 FaceRig 节点),检视器里改只影响当前那个;要两边一致就改 `face_rig.gd` 里的默认值。`face_uv_size` 是 `Vector2`。测试 `test_face_uv_rect_inside_head_island` 会按 min/max 归一化后再判定,允许负边长当翻转用。
- **表情位置/大小/翻转只写在 `face_paint.gdshader` 顶部那段「调表情就改这一段」里**:`face_center`(中心,UV 单位,**往下挪就减小 .y**)、`face_size`(.x 宽 .y 高)、`face_flip_y`(默认 true)。
**FaceRig 故意不传这三个 uniform** —— 早期版本从 `@export` 传过去,会把 shader 里调好的值盖掉;现在 `face_rig.gd` 里没有对应的 export,测试里有一条断言禁止再传。
- 头部孤岛实测:u 0.695~0.996(左右)、v 0.682~0.996(上下,**v 越大越靠头顶**)。贴图第 0 行在上边,所以默认必须 `face_flip_y = true`,否则表情上下颠倒(用户实测确认过)。
- **矩形中心 ± 半个大小不能溢出头部孤岛**:正下方还有一段孤岛(u 0.695~0.935 / v 0.293~0.674),量下来是胸腹那一带,溢出去表情就会糊到肚子上。`tests/test_face.gd` 的 `test_face_rect_inside_head_island` 会直接从 shader 源码里读 `face_center` / `face_size` 再判定,**所以随手调这两个值不会被测试误报,只有溢出才会红**。
- 两个 FaceRig 节点(`player.tscn` 的 `Player/FaceRig`、`character_select.tscn` 的预览 `FaceRig`)现在都不带表情矩形参数了 —— 值只有一份,在 shader 里。
- `FaceRig` 保存 `user://face_preset.json` 中的 expression ID 和 `user://face_paint.png` 暂存笔画;已保存的自绘表情用 `user://face_library/index.json` + 各自透明 PNG 持久化,角色选择页动态显示内置 atlas 表情和本地表情。
- 联机当前表情同步:内置表情发送 ID;`user:` 本地表情发送当前 PNG 字节;笔画层另外发送。**不广播整份本地表情库**。
- `FacePainter`:直接改 RGBA8 字节缓冲。画 = source-over alpha 混合,擦 = 把目标 alpha 乘掉;512² 一次 1MB,按一笔/动作同步一次。
+20 -14
View File
@@ -1,9 +1,23 @@
shader_type spatial;
// 全身 shader:body_mesh 现在只有一个材质 mat_body,所以身体底色照常取它的 BaseColor,
// 脸部**不再单独换材质**,而是在同一个 shader 里,只在指定 UV 矩形内把表情和手绘叠上去。
// 这样脸和头部走的是同一套材质、同一套光照,不会再出现独立面片的色块边界。
// 全身 shader:body_mesh 只有一个材质 mat_body —— 身体底色照常取它的 BaseColor,
// 脸部不再换材质,只在这一个 shader 里把表情和手绘叠到指定 UV 矩形上。
// 脸和周围走的是同一套材质、同一套光照,所以不会再出现独立面片的色块边界。
//
// 贴图 alpha 只表示「墨线覆盖率」,绝不写进空间 ALPHA —— body_mesh 的面片必须保持不透明。
// 贴图 alpha 只代表「墨线覆盖率」,绝不写进空间 ALPHA —— body_mesh 的面片必须保持不透明。
// ================= 调表情就改这一段 =================
// 头部那一段 UV 孤岛的实测范围:u 0.695~0.996(左右),v 0.682~0.996(上下,v 越大越靠头顶)。
// 下面三个值就是「表情画在哪 / 多大 / 正不正」,FaceRig 不会覆盖它们,改完存盘即可。
//
// 表情中心点:**想往下挪就减小 .y**,往左挪就减小 .x
uniform vec2 face_center = vec2(0.8455, 0.639);
// 表情大小:.x 是左右宽度,.y 是上下高度(想只盖住半张脸就把 .y 改小)
uniform vec2 face_size = vec2(0.301, 0.314);
// 上下颠倒了就改成 false(头部 UV 的 v 朝上、贴图第 0 行在上边,所以默认要翻)
uniform bool face_flip_y = true;
// ====================================================
//
// 中心 ± 半个大小必须整个落在上面那段 UV 孤岛里,否则表情会糊到肚子 / 背上去。
uniform sampler2D body_tex : source_color, filter_linear, repeat_disable,
hint_default_white;
@@ -11,14 +25,6 @@ uniform vec4 body_tint : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform float surface_roughness : hint_range(0.0, 1.0) = 0.65;
uniform float surface_metallic : hint_range(0.0, 1.0) = 0.0;
// 脸部在 body_mesh 的 UV 上占的矩形。**模型重导 / 改 UV 之后必须复核这里**:
// 它要整块落在头部那块 UV 孤岛里面,否则表情会画到别的部位上。
uniform vec2 face_uv_min = vec2(0.695, 0.682);
uniform vec2 face_uv_size = vec2(0.301, 0.314);
// 头部 UV 的 v 是「越往上越大」,而贴图第 0 行在上边,所以默认要把 v 翻过来,
// 否则表情上下颠倒。
uniform bool face_uv_flip_y = true;
// 表情底图:内置表情是保留原排布、已去底的透明图集;本地表情是整张透明 PNG。
uniform sampler2D base_tex : source_color, filter_linear, repeat_disable,
hint_default_transparent;
@@ -32,8 +38,8 @@ void fragment() {
// 全身底色都来自 mat_body 的 BaseColor,表情只是盖在脸上
vec3 albedo = texture(body_tex, UV).rgb * body_tint.rgb;
vec2 face_uv = (UV - face_uv_min) / face_uv_size;
if (face_uv_flip_y) {
vec2 face_uv = (UV - (face_center - face_size * 0.5)) / face_size;
if (face_flip_y) {
face_uv.y = 1.0 - face_uv.y;
}
// 用 step 而不是 if:采样留在统一控制流里,免得 mipmap 的导数在分支里未定义
+5 -12
View File
@@ -30,17 +30,13 @@ const FACE_SYNC_INITIAL_DELAY := 1.0
@export_group("画布")
@export var canvas_size: int = 512
## 与两个原始贴图的背景/头部底色完全相同:RGB(137,179,255)。
## 与模型身体贴图的底色一致(FaceRig 启动时会从 mat_body 的 BaseColor 左上角自动读出来)。
## 只给画布和缩略图垫底用,不参与 3D 着色。
@export var skin_color: Color = Color(0.537255, 0.701961, 1.0, 1.0)
@export_group("脸部 UV")
## 脸部矩形在 body_mesh 的 UV 上占的范围(实测自当前模型:头部孤岛是 u 0.695~0.996 / v 0.682~0.996)。
## 它必须整块落在头部那块 UV 孤岛里;模型重导后 UV 一变,表情就会画歪或跑到别的部位上。
@export var face_uv_min: Vector2 = Vector2(0.695, 0.682)
## 脸部矩形的 UV 边长
@export var face_uv_size: Vector2 = Vector2(0.301, 0.314)
## 头部 UV 的 v 越往上越大,而贴图第 0 行在上边 —— 不翻就在脸上上下颠倒
@export var face_uv_flip_y: bool = true
## 表情画在哪 / 多大 / 正不正,全部写在 face_paint.gdshader 顶部的「调表情就改这一段」里
## (face_center / face_size / face_flip_y)。**FaceRig 故意不传这三个 uniform**,
## 免得脚本里的值把 shader 里调好的值盖掉。
var mesh: MeshInstance3D
var material: ShaderMaterial
@@ -88,9 +84,6 @@ func _ready() -> void:
material.set_shader_parameter("body_tint", body_material.albedo_color)
material.set_shader_parameter("surface_roughness", body_material.roughness)
material.set_shader_parameter("surface_metallic", body_material.metallic)
material.set_shader_parameter("face_uv_min", face_uv_min)
material.set_shader_parameter("face_uv_size", face_uv_size)
material.set_shader_parameter("face_uv_flip_y", face_uv_flip_y)
material.set_shader_parameter("paint_tex", painter.texture)
mesh.set_surface_override_material(surface_index, material)
+50 -16
View File
@@ -2,9 +2,10 @@
extends McpTestSuite
## 回归测试:脸部那套「UV 矩形 + 预设 + 画笔」的契约。
##
## body_mesh 现在只有一个材质 mat_body,脸部不再单独换材质,而是靠 face_uv_min / face_uv_size
## 在这块 UV 上圈出脸部矩形。**模型重导或改 UV 之后,脸就会画歪、被裁掉、甚至画到别的部位上,
## 而且不报任何错** —— 所以这里把数值钉死,改模型时先让测试红一次。
## body_mesh 只有一个材质 mat_body,脸部不再单独换材质,而是由 shader 顶部的
## face_center / face_size 在本体 UV 上圈出表情矩形。**模型重导或改 UV 之后,脸就会画歪、
## 被裁掉、甚至糊到肚子上,而且不报任何错** —— 所以这里把「矩形必须落在头部孤岛里」钉死,
## 改模型时先让测试红一次。数值本身从 shader 源码里读,方便随手调。
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
const SHADER_PATH := "res://character/player/face/face_paint.gdshader"
@@ -12,10 +13,7 @@ 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 是浮点,两边各写一份,给点容差
## UV 是浮点,给点容差
const EPS := 0.004
@@ -23,6 +21,31 @@ 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 源码里抓 `uniform vec2 名字 = vec2(a, b);` 的两个数。
func _shader_vec2(code: String, name: String) -> Vector2:
var marker := "uniform vec2 %s = vec2(" % name
var start := code.find(marker)
assert_true(start >= 0, "shader 里找不到 uniform vec2 %s 的默认值" % 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)
@@ -114,6 +137,11 @@ 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,就会把调好的值盖掉
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"]:
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:
@@ -139,20 +167,26 @@ func test_body_mesh_has_single_material() -> void:
## 脸部矩形必须整块落在头部孤岛里,否则表情会画到身体别的部位上。
func test_face_uv_rect_inside_head_island() -> void:
## 表情矩形(中心 ± 半个大小)必须整块落在头部孤岛里,否则表情会糊到别的部位上。
func test_face_rect_inside_head_island() -> 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
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))
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" % [rect_min, head])
"表情矩形左下角 %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 —— 模型改过 UV?" % [rect_max, head])
"表情矩形右上角 %s 超出了头部 UV 孤岛 %s(中心 %s 大小 %s)—— 模型改过 UV 还是值调过头了?"
% [rect_max, head, center, size])
func test_face_shader_has_expected_uniforms() -> void:
@@ -163,7 +197,7 @@ func test_face_shader_has_expected_uniforms() -> void:
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",
"face_center", "face_size", "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)