29 KiB
2026-10-02
审查:Godot 4.7「SubViewport 面部手绘 + 骨骼驱动」方案
用户贴来一份第三方方案(SubViewport 当可绘制面部贴图 + SkeletonModifier3D 把骨骼 Z 转成 shader 参数),问可行性。只做评审,未改任何代码。
结论:原理可行(SubViewport-as-texture + shader 混合是标准做法),但原文代码有 4 处硬伤:
- shader
render_mode ... skip_vertex_transform却没写 vertex() 手动变换 → 模型位置错。删掉该 render_mode。 PaintSurface._input在后台 SubViewport 里收不到鼠标事件(Viewport 之间不派发输入)→ 必须放进SubViewportContainer,或手动push_input。SkeletonModifier3D没有skeleton属性,只有get_skeleton()(已查 4.5 文档确认)。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):
- 脚本目前挂在
Body/AnimationPlayer上,而player.gd、player_sync.tres、tests/test_net_replication.gd都找Body/PlayerAnimator—— 需要新增独立 PlayerAnimator 节点。 - 该节点的 6 个 float 导出在 .tscn 里被序列化成
null(实测运行时是Nil),会覆盖脚本默认值。 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自身上原地抬头低头。fov82.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, 右下角一个VersionLabel。 - 四个按钮 开始游戏 / 联机 / 设置 / 退出,只有
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.pngface_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 了)
- Godot 4 的 canvas 没有擦除混合模式,
draw_set_blend_mode也没了 → 橡皮改成自己乘 alpha。 Image.set_data()在 4.7 变成 5 个参数 → 改用Image.create_from_data。- 导出的节点引用在场景实例化时解析成 null → 改成
mesh_path: NodePath+_ready()里get_node_or_null。 - shader 里
vec2不能用source_color(只给 vec3/vec4/sampler)。 - 画布一开始只显示笔画层,等于让玩家对着空气画 → 底下垫一层预设 TextureRect。
- 拖拽判定原本看 motion 的
button_mask,MCP 注入的 motion 不带这个字段 → 改成自己记_drawing。 - 排查时一度以为画笔没生效,其实黑点画在脸的黑色线条上被淹没了 —— 换到纯蓝区域才看见。
诊断顺序记一下:先
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_coloruniform,取FaceRig.skin_color。透明底图的像素用来混合肤色和墨线,玩家笔画再覆盖;贴图 alpha 只代表线条覆盖率。 - 透明 PNG 本身仍然无底色;只是在 3D 面片上把透明区填回模型肤色,因此不再露内部。
- 更新 shader 注释,明确区分透明图像 alpha 与空间表面 alpha。
tests/test_face.gd增加skin_coloruniform 检查,并断言 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_meshsurface 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 区域,而非主头皮肤面上的一块纯纹理。截图中整块偏色边界延着区域轮廓,和此一致。
可能还剩两类因素:
skin_color是 GDScript 传入的source_coloruniform,身体是BaseColorsRGB 纹理 sampler;要验证它们在 shader 中最后是否到达相同 linear ALBEDO(不要只比原始 RGB 数字)。色彩空间若不一致,会造成整块色偏。- 面片内部法线插值/三角化与主体曲面不同,边缘法线虽然在 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.6950.935 / v 0.2930.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 工程。