Files
2026-10-03 05:40:26 +08:00

323 lines
29 KiB
Markdown
Raw Permalink 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.
# 2026-10-02
## 审查:Godot 4.7「SubViewport 面部手绘 + 骨骼驱动」方案
用户贴来一份第三方方案(SubViewport 当可绘制面部贴图 + SkeletonModifier3D 把骨骼 Z 转成 shader 参数),问可行性。只做评审,未改任何代码。
结论:**原理可行**(SubViewport-as-texture + shader 混合是标准做法),但原文代码有 4 处硬伤:
1. shader `render_mode ... skip_vertex_transform` 却没写 vertex() 手动变换 → 模型位置错。删掉该 render_mode。
2. `PaintSurface._input` 在**后台 SubViewport 里收不到鼠标事件**(Viewport 之间不派发输入)→ 必须放进 `SubViewportContainer`,或手动 `push_input`。
3. `SkeletonModifier3D` **没有 `skeleton` 属性**,只有 `get_skeleton()`(已查 4.5 文档确认)。
4. `save_paint` 没 `await RenderingServer.frame_post_draw` → 存到空帧。
标定类问题:`get_bone_pose_position()` 是相对父骨的姿态,Z 轴受父骨朝向影响,GLTF 导入后与 Blender 不一致,z_min/z_max 必须实测标定;`face_index` 只做了亮度乘,没真正切换表情图层。
与本项目相关的判断:角色是 `character-male-e.glb`,需先确认有独立面部材质槽 + 展平 UV;多人下 SubViewport/材质必须每实例一份,远端玩家的画要按 PNG 字节流同步。
## 重写 player_animator.gd(7 状态)
按新枚举 `{IDLE, WALK, CROUCH, RUN, FALL, SNEAK, JUMP}` 重写 `character/player/player_animator.gd`。
改动:加 `class_name PlayerAnimator`(`player.gd` 用它当类型)、新增 `Stance{STAND,CROUCH,SNEAK}`、
`set_motion(velocity, on_floor, sprinting=false, stance=STAND)`、片段基准速度扩到 walk/run/crouch/sneak。
实测结论(用 @tool 副本跑 McpTestSuite,14 条断言全过):
- 短片段名查不到,必须用 `"库名/片段名"`(`walk/walk`);`_ready()` 里扫库解析一次并写入 `_clips`。
- 状态映射、自动绑兄弟 AnimationPlayer、`play("walk/walk")`、loop_mode 补丁、倍率 5/5≈1.0 均正确。
- `class_name` 之后 `self is AnimationPlayer` 是编译期错误 → 脚本不能挂在 AnimationPlayer 节点上。
**未改、留给用户决定的场景问题**(`player.tscn`):
1. 脚本目前挂在 `Body/AnimationPlayer` 上,而 `player.gd`、`player_sync.tres`、`tests/test_net_replication.gd` 都找 `Body/PlayerAnimator` —— 需要新增独立 PlayerAnimator 节点。
2. 该节点的 6 个 float 导出在 .tscn 里被序列化成 `null`(实测运行时是 `Nil`),会覆盖脚本默认值。
3. `tests/test_net_replication.gd` 里的 `REQUIRED_CLIPS` 仍是旧的 `sprint`,需要同步成新状态集。
## 收尾:按用户决定接线 + 清场景脏数据
用户决定「脚本就挂在 `Body/AnimationPlayer` 上」,据此改完并验证(`test_run` 20/20,320 条断言;实机跑 player.tscn 确认 idle→fall 切换正常)。
- `player_animator.gd`:文档改成「脚本挂在 AnimationPlayer 上、`animation_player` 留空自绑自身」。
- `player.gd`:`$Body/PlayerAnimator` → `$Body/AnimationPlayer`,场景结构注释同步;`_ride_zipline` 里 `tan` 改名 `tan_dir`(消 built-in 遮蔽警告)。
- `player_sync.tres`:同步字段路径改 `Body/AnimationPlayer:anim_state` / `:anim_speed`。
- `player.tscn`:删掉被序列化成 `null` 的 6 个 float 导出(`blend_time` 等)—— 实测运行时确实是 `Nil`,会覆盖脚本默认值。
- `tests/test_net_replication.gd`:路径、`REQUIRED_CLIPS` 全部同步;新增 `_find_clip()` 按库解析(短名断言会假红)。
- `tests/test_zipline.gd`:`test_interact_action_exists` 先调 `InputMap.load_from_project_settings()` —— 编辑器进程的 InputMap 不带项目动作(实测 `interact`/`jump` 均为 false,只有 `ui_accept`),这条一直假红。
- `world/zipline/zipline.gd`:`tan` → `tan_dir`。
**Godot MCP 注意**:`node_get_properties` 读不到脚本的 @export 属性(`unknown_fields`),但 `node_set_property` 能写进去;数值标量经 MCP 会变成字符串(`node_set_property` 返回 `"0.15"`),`batch_execute` 里的 `set_property` 直接报 INTERNAL_ERROR。改 `.tscn` / `.tres` 这类被编辑器占用的文件,直接用文本工具写反而更省事(`.tscn` 可写、`.tres` 报 EBUSY,用 `filesystem_manage(op="write_text")`),写完 `scene_open(force_reload=true)` 让编辑器重新读盘。
## 接线:Ctrl 蹲下 / Ctrl+移动 潜行
用户定的规则:`crouch` 绑 Ctrl;**蹲着不动 = CROUCH,蹲着走 = SNEAK**。
- `project.godot`:用 `input_map_manage(op="ensure_binding", {action:"crouch", event_type:"key", keycode:"Ctrl"})`
加的动作,落在 keycode 4194326,和已有的 `sprint`(Shift/4194325) 同一套写法。
- `player_animator.gd`:**删掉 `Stance` 枚举**,`set_motion` 第 4 个参数改成 `crouching: bool`。
「蹲 / 潜行」的区分放回表现层(`moving := 水平速度 >= idle_threshold`),控制器只传「按没按 Ctrl」。
优先级改成 空中 > 蹲 > 移动。
- `player.gd`:调用点传 `Input.is_action_pressed("crouch")`;操作说明补上 Ctrl。
- `tests/test_net_replication.gd`:加 `test_crouch_action_exists`(先 `InputMap.load_from_project_settings()`)。
验证:`test_run` 21/21;另用 @tool 副本跑了 4 组状态映射断言(Ctrl+不动→CROUCH、Ctrl+移动→SNEAK、
Ctrl+Shift→仍是 SNEAK、空中→JUMP/FALL 优先于蹲),片段也确认切到了 `crouch/crouch` / `sneak/sneak`。
**踩坑**:`test_run` 的脚本缓存按路径,删掉重建同名文件会跑旧版本 —— 第一次探针就因此假失败
(旧版 `stance: int` 收到 `true` 被当成 `Stance.CROUCH`)。换文件名才拿到真实结果。
**未做**:蹲下目前不改移动速度、也不改碰撞胶囊高度 —— 潜行时用走速(5 m/s)播 sneak 片段,
`speed_scale` 会被 clamp 到上限 1.6。要不要减速/压低碰撞盒等用户定。
## 重做:第三人称 → 第一人称控制器
`player.gd` + `player.tscn` 改造。`test_run` 21/21,实机确认 `CameraRig.position.y=1.6`、`Body.visible=false`。
- **场景**:删掉 `SpringArm`,`Camera` 从 `CameraRig/SpringArm/Camera` 挪到 `CameraRig/Camera`。
`CameraRig` 变成「眼睛」,架在 `eye_height` 高,只转 yaw;pitch 在 `Camera` 自身上原地抬头低头。
`fov` 82.2 → 75(82 是给第三人称的,第一人称太鱼眼)。
- **`player.gd` 新增两个导出**:`eye_height`(1.6,按 1.8 胶囊定,与模型高矮无关)、
`hide_own_body`(true,只藏本机那一份,远端照常显示)。不藏的话相机就在模型里/贴头顶。
- **删掉 `turn_speed`**:第一人称里身体不再朝移动方向转身,改成始终跟视线一致(`_apply_look()` 里
`body.rotation.y = _yaw`),这个平滑参数没用了。
- **新增 `_apply_look()`**(3 处调用:鼠标、滑索、_ready):鼠标一动就落节点,视角跟手不等物理帧。
- pitch 范围从 -80/45 放到 ±89(第一人称要能抬头低头)。滑索的视角对齐沿用原来的 3.0 慢速率。
**已知遗留**:`HealthLabel`(y=2.05) 和 `InteractHint`(y=1.75) 是玩家自己位置的 Label3D,
第一人称下平视看不到(都在相机所在的竖直平面上,超出 FOV),但抬头会看到自己的血条 ——
要干净的话得改成 CanvasLayer 的 HUD(会连带改 `test_zipline` 里断言 `Player/InteractHint` 的那条)。
**MCP 限制**:`game_manage(op="input_mouse")` 注入的 `InputEventMouseMotion` 不带 `relative` 增量,
所以鼠标转视角没法用 MCP 验证(`input_key`/`input_action` 是正常的,用 `H` 键把血条打到 90 验证过)。
## 方案:面部贴图切换 + 游戏内自绘(只出方案,未写文件)
用户给了 `mouse_表情测试.png`,问怎么在游戏内切面部贴图、并让玩家自己画。
**先探了模型**(临时探针读各 mesh 的 surface/UV/材质,跑完删了):面部早就被拆成了独立
surface —— `body_mesh s1 "face"`,UV 孤岛 u[0.142,0.356] v[0.038,0.238],自带材质和贴图。
所以**不用改 Blender**,直接 `set_surface_override_material(1, ...)`。
给的方案:面部 shader(把孤岛 UV 重映射到 0~1,`ALBEDO = mix(base_tex, paint_tex, paint.a)`)
+ `base_tex` = 预设方形 PNG(切换就换它)+ `paint_tex` = 一个 512² 透明 SubViewport 的**笔画层**。
画布只装笔画不装底图,所以橡皮擦掉笔画露出来的是预设脸,语义正确。
关键设计点(也是给之前那份第三方方案纠错的地方):**绘画 UI 是可见的 `CanvasLayer +
SubViewportContainer`,输入天然可达** —— 不是把 SubViewport 当后台渲染目标(那样 `_input` 收不到事件)。
遗留待用户拍板:现有预设要不要裁成方形(重映射后必须裁,1024² 里裁 x145..365 y32..251);
联机时脸要不要 RPC 同步合成后的 PNG。
## 新建:游戏开始菜单(只有界面,无逻辑)
`ui/main_menu.tscn` + `ui/main_menu.gd`(新目录 `ui/`)。
- 背景用 `GradientTexture2D` 取游戏天空的三段色(zenith 0.09/0.30/0.78 → mid → horizon),
和 `world/sky/cartoon_sky.gdshader` 的调色一致。
- 结构:`MainMenu(Control) → Background(TextureRect) / Center(CenterContainer) → Box → Title + Buttons`,
右下角一个 `Version` Label。
- 四个按钮 开始游戏 / 联机 / 设置 / 退出,**只有 `pressed` → 自定义信号**,没有任何开始/退出/联机实现。
- 按钮样式用 3 个 `StyleBoxFlat`(normal/hover/pressed)**挂在 `Buttons` 这个 VBoxContainer 上**,
靠主题继承一次性作用到四个按钮,不用逐个写。
- 实测(跑起来截图 + `get_ui_elements`):版式正确、中文正常渲染。
**发现**:`Net` autoload 的「联机测试」面板默认可见且在同位置,会把菜单中间的按钮盖住 ——
接线时必须先处理它(隐藏,或让菜单的「联机」按钮接管)。这条已记进 MEMORY.md。
## 实现:角色选择 + 脸部预设选择 + 脸部绘制
按之前的方案落地了。`test_run` **26/26 通过**(含新增的 face suite 5 条),实机跑通并截图确认。
**新增**
- `character/player/face/face_paint.gdshader` — 孤岛 UV 重映射 + `mix(base, paint, paint.a)`
- `character/player/face/face_painter.gd` — 笔画层,直接改 RGBA8 字节缓冲
- `character/player/face/face_rig.gd` — 挂 surface 1 的材质、切预设、存读档(`user://face_*.json/png`)
- `character/player/face/presets/face_01.png` `face_02.png` — 从 `mouse_表情测试.png` 裁的 512² 方形预设
- `ui/paint_panel.tscn/.gd` — 绘画面板(颜色 / 粗细 / 橡皮 / 清空 / 取消 / 完成)
- `ui/character_select.tscn/.gd` — 3D 预览(自转)+ 预设缩略图 + 自己画
- `tests/test_face.gd` — 钉住「surface 1 叫 face、UV 孤岛落在 shader 的框里、预设是正方形」
**改动**:`player.tscn` 加 `FaceRig` 节点;`main_menu.gd`「开始游戏」→ 角色选择,并把 Net 调试面板收起来。
**踩到的坑(都补进 MEMORY.md 了)**
1. Godot 4 的 canvas **没有擦除混合模式**,`draw_set_blend_mode` 也没了 → 橡皮改成自己乘 alpha。
2. `Image.set_data()` 在 4.7 变成 5 个参数 → 改用 `Image.create_from_data`。
3. 导出的**节点引用**在场景实例化时解析成 null → 改成 `mesh_path: NodePath` + `_ready()` 里 `get_node_or_null`。
4. shader 里 `vec2` 不能用 `source_color`(只给 vec3/vec4/sampler)。
5. 画布一开始只显示笔画层,等于让玩家对着空气画 → 底下垫一层预设 TextureRect。
6. 拖拽判定原本看 motion 的 `button_mask`,MCP 注入的 motion 不带这个字段 → 改成自己记 `_drawing`。
7. 排查时一度以为画笔没生效,其实黑点画在脸的黑色线条上被淹没了 —— 换到纯蓝区域才看见。
诊断顺序记一下:先 `print` 确认输入到了(`[PP]`)→ 再确认印章落了(`[FP] stamp`)→ 再看缓冲字节(`probe a=255`)。
## 收尾:入口换成开始菜单 + 联机脸同步
`test_run` **28/28 通过**。两件事都用实机验证过(入口流程用 MCP 点按钮走通;脸同步用**两个
headless 实例** `--host` / `--join 127.0.0.1` 真连了一次)。
**入口场景**
- `project.godot` 的 `run/main_scene` 改成 `res://ui/main_menu.tscn`(用 `project_manage(set_main_scene)` 改的)。
- `net.gd`:加 `WORLD_SCENE` / `MENU_SCENE`、`_enter_world()`、`_go_to()`、`set_menu_visible()`;
`host_game()` 和 `_on_connected_to_server()` 之后切进世界;`leave_game()` 切回菜单并把 `_spawner` 置空。
- **关键**:世界是开局后才加载的,`bind_spawner()` 必须补生成已经连上的玩家,否则主机在菜单里
开好服、进世界时没有角色。
- `main_menu.gd`:「确定」→ `Net.host_game()`;用 `Net.set_menu_visible()` 代替原来的节点路径 hack。
- 新增测试 `test_main_scene_is_the_menu`(防有人把入口改回世界)。
**脸同步**
- `FaceRig` 加三个方法:`_pull_remote_face` / `_rpc_request_face` / `_rpc_send_face`。
**用「谁缺谁来要」而不是出生时推** —— 各端玩家节点出现的先后顺序不保证,推的话很可能推给一个
还不存在的节点。非本机玩家在 `_ready` 里主动去要,带重试。
- `FacePainter` 加 `to_png_bytes()` / `set_image()`,走内存不落盘。
- **踩到的坑**:第一次跑双实例,客户端刷了一屏
`Node not found: "Main/<id>/FaceRig"` / `Failed to get path from RPC` ——
主机生成玩家比客户端早几帧,主机立刻去要时客户端还没建出那个节点。
加 `FACE_SYNC_INITIAL_DELAY = 1.0`(首次要之前先等一秒)后两边都干净了。
- 验证结果:HOST 和 JOIN 都打出「收到 peer X 的脸,png=1930 字节」,且无任何报错。
1930 正好是 `user://face_paint.png` 的大小,说明笔画真的传过去了。
- 新增测试 `test_face_sync_methods_exist`(钉住三个方法名)。
**找到 Godot 可执行文件**:`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64_console.exe`
(注意那一串带 .exe 的其实是个**目录**)。双开调试命令:
`<godot> --headless --path E:/Godot/agame -- --host` / `-- --join 127.0.0.1`。
## 修 bug:点击「自己画」不是空白画布 + 透明脸资源落地
用户上一轮报「选预设后点自己画不是空白」。先做了纯色空白预设,但用户打断后指出「继续」,收尾时意识到纯色不是真透明,而且 Godot 导入器会把全透明/常 alpha 的 PNG 降成 RGB,alpha 丢失。
最终做法:
- `face_01.png` / `face_02.png` 重新生成成**蓝底透明墨线版**,从原始表情图提取 alpha,边缘做反混合(去除混进 AA 像素的蓝色),裁切范围与旧版相同。
- **不再保留磁盘空白 PNG**。`FaceRig._no_base_tex()` 用运行时 `Image.create(2,2,RGBA8)` 造真正透明的 ImageTexture。
- `character_select.gd._on_draw()` 改成 `_paint_panel.open(_rig, true)`。
- `paint_panel.gd.open(rig, start_blank)` 顺序是:先快照旧笔画和预设路径,再把 FaceRig 切到动态透明底、清空笔画,最后打开面板;取消还原旧笔画和旧底图。
- 缩略图以及画布是肤色底 → 墨线预设 → 笔画三层合成。画布在「自己画」时底图透明,因此显示成 FaceRig.skin_color 的纯色,确认是从空白开始。
- 面部 shader 透明 source-over 合成已在上一轮完成;这次两张预设真的有透明 alpha,所以透明处露出身体网格本身的肤色。
- 面板说明改成「露出来的是底下的脸」。
实测:先选择 face_01(角色脸上出现线稿),点「自己画」(绘图面板纯肤色,角色预览脸无预设线稿),点取消(原预设线稿恢复)。`test_run` 29/29。
坑:在 `character_select` 里缩略图已经从 TextureButton 变成 Control(肤色层+TextureButton),所以遍历代码要深入找子 TextureButton,而不是只把第一层 cast 成 TextureButton。
## 问题:透明表情贴图导致面部镂空(用户实测)
用户确认「脸部直接镂空,能看到玩家内部」。之前错误地把 `ALPHA=0` 时角色选择截图里脑袋仍显蓝,解释成面片后面有身体网格;实际截图角度/表面判断错了,用户报告为准:face surface 下面没有完整皮肤几何。不能把贴图透明等同于让整个 3D 面片透明。
**修复**:
- `face_paint.gdshader` 删除空间 `ALPHA` 输出和透明渲染假设;面部 3D surface 保持不透明。
- 新增 `skin_color` uniform,取 `FaceRig.skin_color`。透明底图的像素用来混合肤色和墨线,玩家笔画再覆盖;贴图 alpha 只代表线条覆盖率。
- 透明 PNG 本身仍然无底色;只是在 3D 面片上把透明区填回模型肤色,因此不再露内部。
- 更新 shader 注释,明确区分透明图像 alpha 与空间表面 alpha。
- `tests/test_face.gd` 增加 `skin_color` uniform 检查,并断言 shader 不写空间 `ALPHA`。
验证:Godot 4.7.2 实机载入角色选择场景无 shader/脚本错误;`test_run` **29/29**。
## 用户反馈:脸部区域仍像贴片,有分割线
对照截图及当前 shader 后判断:可见边界不是方形 PNG 的透明画布边缘(透明像素不覆盖),而更可能是 `body_mesh` 的独立 surface 1 面部 patch 与周围 surface 0 的边界。
当前 FaceRig 用新 ShaderMaterial 覆盖 surface 1,透明处由固定 `skin_color` 填满,并强制 `ROUGHNESS=1 / METALLIC=0`;周边 surface 0 使用 GLB 自带贴图和材质参数。哪怕 albedo RGB 看起来接近,光照、法线、roughness 差异也会让 patch 边缘显出来。之前改成常量肤色只是堵洞,**不是无缝融合**。
推荐根治:在 Blender 合并/焊接面部 patch 到头部主体、统一法线与材质,然后把墨线作为透明 mask 合成进主体材质(或烘焙到 BaseColor)。先只调肤色/roughness 可缓解但不保证消除几何/法线缝。用户那一轮只问原因和方案,尚未改模型。
## 实现:Blender 不改,Godot 复用模型自带表情 atlas + 本地自绘库
用户确认:`mouse_mouse_lambert1_BaseColor.png` + `mouse_表情测试.png` 是模型自带图;要保留 Blender 原来的 UV 选表情概念,玩家自绘表情落盘并出现在表情列表。
- 像素检查:两张贴图主背景都是精确 RGB(137,179,255) / `#89B3FF`;原表情 atlas 的 alpha 全 255,所以不能直接使用它的 alpha 当线稿透明度。
- 保持 Blender/GLB 原样。生成 `character/player/face/presets/face_atlas_transparent.png`:1024² 透明副本,原 atlas UV 坐标不变;纯蓝底去透明,AA边缘反混合,不写回原图。
- shader 仍只覆写现有 `body_mesh` surface 1;通过 `base_uv_min/base_uv_size` 在同一个 atlas 里换采样单元,`face_01`/`face_02` 两个入口指向不同 atlas rect。贴图 alpha 只作颜色覆盖,face surface 始终不透明。肤色参数正好等于两张源图背景色。
- `FaceRig`:以 expression ID 选择内置表情和本地表情;升级 `user://face_preset.json` 存 expression ID,旧版 `preset` 路径仍能迁移。内置 atlas ID 在两个节点共同使用。
- 本地表情库:每张独立透明 PNG 存在 `user://face_library/`,`index.json` 保存 ID/显示名称/文件名。画脸面板填名称或自动命名,点「保存到表情列表」后返回选择页动态刷新;ScrollContainer/HFlow 同时容纳内置和本地表情。
- 联机:内置表情发 ID,自绘 user face 发 PNG 字节,笔画层另发 PNG 字节。双 headless 的 Host/Join 日志两边都收到 `expression=user:... custom_png=1623 paint_png=1109`,无节点路径/RPC错误。
- 实机验证列表里有两张内置、玩家本地表情;在画布画一笔后保存,重启主菜单仍可看到本地表情条目。**测试时确实在本机 user:// 写入一条样例「我的表情 1」**,由测试画的一条斜线构成,没有删本机数据。
- `test_run` 全套 **31/31**;主菜单可启动,角色选择场景和脚本日志无错误。
注意:Face shader 仍把 face surface roughness 固定为 1.0,尽管底色数值已确认一致,模型面片法线/受光边界仍可能保留视觉接缝;这与 atlas 选择/本地库是另一个问题。
## 修复表情区域色差:对齐 PBR 参数
用户问透明脸片和主体重叠处颜色不一致的原因及修复。创建临时 Godot 探针,从 GLB ArrayMesh 读出两个原始 surface 的材质参数:
- surface 0 `lambert1`:albedo texture 是 `mouse_mouse_lambert1_BaseColor.png`,albedo_color=白,roughness=`0.552786409854889`,metallic=0,normal map 无。
- surface 1 `face`:albedo texture 是 `mouse_表情测试.png`,albedo_color=白,roughness=0.5,metallic=0,normal map 无。
当前 FaceRig 新 shader 原先将 roughness 写死成 1.0;这确实与主体 0.5527864 差距很大,是色块分界的主要 PBR 原因(虽然 RGB 底色相同)。
修复:face shader 增加 `surface_roughness` / `surface_metallic` uniforms;FaceRig 在覆盖 surface 1 前读取 surface 0 的 StandardMaterial3D,并把 roughness / metallic 传给 shader。GLB 当前值 0.5527864 / 0.0,因此和主体着色参数一致;将来重新导入模型也会自动跟随,不用写死值。测试增加 PBR 参数契约,确认 roughness 与已知 GLB 值一致。`test_run` **32/32**。实机运行 player / character_select 场景无 shader/脚本报错。
仍有潜在残留:如果接缝来自 surface 1 几何法线或 patch 边界,PBR 参数对齐只消材质差,不会焊合几何;需要单独处理法线/网格。临时测试脚本 `test_material_probe.gd` 已删除。
## 继续诊断:PBR 粗糙度对齐后仍有色块边界
用户发来头部裁图,边界仍可见。不能再说「只是 roughness」:surface 0/1 的粗糙度已分别核实为 0.5527864 / 0.5,运行时 shader 已动态读取 surface 0 并同步 roughness 与 metallic(目前两项一致)。
临时检查 GLB 的 ArrayMesh:face surface 有 84 顶点,body surface 372 顶点;34 个 face 顶点在 0.02 内可找到主体顶点,配对法线 dot≈1(完全一致);其余 face 内部顶点在主体 surface 没有对应几何。这说明 face 是一片独立补面/独立 shading 区域,而非主头皮肤面上的一块纯纹理。截图中整块偏色边界延着区域轮廓,和此一致。
**可能还剩两类因素**:
1. `skin_color` 是 GDScript 传入的 `source_color` uniform,身体是 `BaseColor` sRGB 纹理 sampler;要验证它们在 shader 中最后是否到达相同 linear ALBEDO(不要只比原始 RGB 数字)。色彩空间若不一致,会造成整块色偏。
2. 面片内部法线插值/三角化与主体曲面不同,边缘法线虽然在 34 个重合顶点上吻合,面片内部其余顶点没有主体对应点。roughness/metallic 对齐不可能自动修正几何。
当前任务只要求解释仍可见原因,暂未改着色或网格。`test_run` 32/32(临时探针已删除)。
## 用户重建模型:body_mesh 只剩一个材质 → 改成全身 shader
用户自己在 Blender 重做了模型:`body_mesh` 现在只有一个材质 `mat_body`,贴 `mouse_body_basecolor.png`(纯色底);
耳朵拆成 `ear_l_mesh`/`ear_r_mesh`(`mat_ear` + `mouse_ear_basecolor.png`),手臂/腿/尾巴也各是独立 mesh 节点。
旧的 `mouse_mouse_lambert1_BaseColor.png` 和 `mouse_表情测试.png` 被删除,所以测试里引用它们的地方必须换掉。
这次改动正好从根上消掉了「脸是独立材质面片」的色差来源:**现在没有第二个材质槽了**。
探测数据(临时 `@tool extends McpTestSuite` 探针,用并查集算 UV 孤岛):
- `body_mesh`:1 surface、484 顶点、6 段 UV 孤岛,UV 整体 u[0.004,0.996] v[0.259,0.996]。
- 最高那段(= 头部)UV 是 u[0.695,0.996] v[0.682,0.996];**模型里没有专门的「脸」孤岛**,头部是一整块连续展开。
- mat_body:roughness 0.65、metallic 0、albedo_color 白;没有顶点色、没有第二套 UV。
改造:
- `face_paint.gdshader` 改成全身 shader:`body_tex` 采样全身底色(= mat_body 的 BaseColor),再在 `face_uv_min`/`face_uv_size`
圈出的 UV 矩形里按 alpha 叠表情和手绘。遮罩用 `step()` 相乘而不是 `if`,采样留在统一控制流里。
- `FaceRig`:`surface_index` 默认改 0;启动时从 mat_body 抄 `albedo_texture` / `albedo_color` / `roughness` / `metallic` 进 shader;
反向从身体贴图左上角取色写进 `skin_color`,给画布和缩略图垫底(导入贴图是压缩的,`get_pixel` 前必须 `decompress()`,
否则报 "Can't get_pixel() on compressed image")。`face_uv_size` 从 float 改成 `Vector2`。
- `tests/test_face.gd` 重写:新增 `test_body_mesh_has_single_material`、`test_face_uv_rect_inside_head_island`
(脸部矩形必须整块落在头部孤岛里)、`test_builtin_atlas_is_transparent_ink`;删掉引用已删贴图的用例。
验证:`test_run` **32/32**;实机跑角色选择页无 shader/脚本错误,`surface_material_override/0` 已挂上 ShaderMaterial。
**未完成**:脸部矩形默认填的是「整块头部 UV 孤岛」(0.695, 0.682, 0.301, 0.314),是我量出来的占位值。
**用户已确认:脸部范围就是整块头部 UV 孤岛**(表情铺满头部展开),所以默认值即为最终值,不需要再改。
也因此内置表情图集 rect(原来是给方形脸孤岛的)会按头部块的 0.301×0.314 比例铺上去。
待确认的只剩视觉效果:表情在头部展开上的朝向/比例是否合适(v 轴方向、拉伸),以及画布 512² 铺满整块头是否够用。
### 用户反馈:表情上下颠倒 + 要往下移
原因确认:头部孤岛 UV 的 v 越大越靠上(实测 v=0.682 → y=0.47,v=0.933 → y=3.00),而贴图第 0 行在上边,
所以原来的映射必然上下颠倒。修法:shader 加 `uniform bool face_uv_flip_y`(在 face_uv 算完之后 `1.0 - face_uv.y`),
FaceRig 加同名 `@export`(默认 `true`),并把 uniform 推给 shader。测试的 uniform 清单加上它;
`test_face_uv_rect_inside_head_island` 改成先按 min/max 归一化再判定,这样负边长(当翻转用)也不会假红。
**往下移的旋钮**:`face_uv_min.y` 不能低于头部孤岛底边 0.682,否则矩形会伸进正下方那段孤岛
(u 0.695~0.935 / v 0.293~0.674,实测量到是胸腹那一带),表情会同时糊在肚子上。
正确做法是钉住 `face_uv_min.y = 0.682`,改小 `face_uv_size.y`。
提醒过用户:这两个 FaceRig 节点(player.tscn / character_select.tscn)各有一份这三个值,要两边一致就改脚本默认值。
`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`,其余仍是指定脚本。
运行失败于导出模型阶段(`export_split.py:100`),Blender glTF exporter 报:`TypeError: foreach_get(..) sequence length mismatch given 3120, needed 0`,堆栈在读取 `body` 网格 UV 层。`--python-exit-code 1` 令错误以非零码返回。脚本在模型导出前若已有 `output/mouse.glb` 会删除;本次执行前该文件不存在,因此没有丢失模型。失败发生在动画导出循环之前;7 个现有动画 GLB 与 `bake_ref.json` 时间戳仍是原有 14:43,未被覆盖。
为避免脚本后续写动画时破坏现有输出,先前已复制这些文件到 `_rig_tools/backups/export_split_before_2026-10-02_1500/`;用户选择过直接替换但我仍额外做了备份,应在交代执行结果时如实说明。没有修改 `export_split.py` 或 Blender 工程。