This commit is contained in:
2026-10-02 15:42:34 +08:00
parent 698cb02c21
commit faf799837f
22 changed files with 759 additions and 302 deletions
+41 -19
View File
@@ -1,29 +1,51 @@
shader_type spatial;
// 面部 = 两层贴图的合成:底图(预设脸)+ 笔画层(玩家自绘),后者带 alpha 盖在前面。
// 两层都用「面部孤岛重映射后的 UV」采样,所以一张方形小图就能正好铺满整张脸。
// 全身 shader:body_mesh 现在只有一个材质 mat_body,所以身体底色照常取它的 BaseColor,
// 脸部**不再单独换材质**,而是在同一个 shader 里,只在指定 UV 矩形内把表情和手绘叠上去。
// 这样脸和头部走的是同一套材质、同一套光照,不会再出现独立面片的色块边界。
//
// 为什么不是直接把贴图换掉:画布只装笔画、不装底图,橡皮擦掉笔画后露出来的是预设脸,
// 而不是把脸擦成黑洞。这也是它能一边切预设一边保留手绘笔画的原因。
// 贴图 alpha 只表示「墨线覆盖率」,绝不写进空间 ALPHA —— body_mesh 的面片必须保持不透明。
// 面部 UV 孤岛(实测自 mouse.glb 的 body_mesh surface 1 "face"):
// u 0.142~0.356,v 0.038~0.238。v 往下外扩到 0.031 是为了取成正方形,避免拉伸。
uniform vec2 face_uv_min = vec2(0.142, 0.031);
uniform float face_uv_size : hint_range(0.01, 1.0) = 0.214;
// 预设脸。没接上时给张白图,至少不会一片黑
uniform sampler2D base_tex : source_color, filter_linear, repeat_disable,
uniform sampler2D body_tex : source_color, filter_linear, repeat_disable,
hint_default_white;
// 玩家笔画层(一个透明底的 SubViewport)。没接上时给张全透明图,等于没画
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;
uniform vec2 base_uv_min = vec2(0.0, 0.0);
uniform vec2 base_uv_size = vec2(1.0, 1.0);
// 玩家笔画层(透明底)
uniform sampler2D paint_tex : source_color, filter_linear, repeat_disable,
hint_default_transparent;
void fragment() {
vec2 uv = (UV - face_uv_min) / face_uv_size;
vec4 base = texture(base_tex, uv);
vec4 paint = texture(paint_tex, uv);
// 全身底色都来自 mat_body 的 BaseColor,表情只是盖在脸上
vec3 albedo = texture(body_tex, UV).rgb * body_tint.rgb;
ALBEDO = mix(base.rgb, paint.rgb, paint.a);
// 顶掉 GLB 里 lambert1 那套手感,不然脸会比身体亮一块
ROUGHNESS = 1.0;
METALLIC = 0.0;
vec2 face_uv = (UV - face_uv_min) / face_uv_size;
if (face_uv_flip_y) {
face_uv.y = 1.0 - face_uv.y;
}
// 用 step 而不是 if:采样留在统一控制流里,免得 mipmap 的导数在分支里未定义
float mask = step(0.0, face_uv.x) * step(face_uv.x, 1.0)
* step(0.0, face_uv.y) * step(face_uv.y, 1.0);
vec4 base = texture(base_tex, base_uv_min + face_uv * base_uv_size);
vec4 paint = texture(paint_tex, face_uv);
// 透明处保持身体底色 —— 所以脸和周围不可能出现底色差
albedo = mix(albedo, base.rgb, base.a * mask);
albedo = mix(albedo, paint.rgb, paint.a * mask);
ALBEDO = albedo;
ROUGHNESS = surface_roughness;
METALLIC = surface_metallic;
}
+234 -71
View File
@@ -1,54 +1,58 @@
class_name FaceRig
extends Node
## 角色的脸:把 mesh 上那个「face」surface 换成自定义材质,
## 材质把「预设底图」和「玩家自绘的笔画层」合成起来。
##
## 笔画层是一张常驻的 Image(不是 SubViewport)—— 这样即使绘画面板没打开,
## 游戏内的脸也一直是玩家画的那张。绘画面板只是把 painter.texture 显示出来,
## 把鼠标坐标换算好之后喂给 painter。
## 角色脸部:body_mesh 现在**只有一个材质**,所以这里不再换材质,
## 而是把 mat_body 换成「全身 shader」:身体照常取它的 BaseColor,
## 只在脸部的 UV 矩形内叠加表情贴图和玩家手绘。
const SHADER_PATH := "res://character/player/face/face_paint.gdshader"
## 存档:选的是哪张预设
const SAVE_PRESET := "user://face_preset.json"
## 存档:笔画层(透明底 PNG)
const ATLAS_PATH := "res://character/player/face/presets/face_atlas_transparent.png"
const LEGACY_PRESET_SAVE := "user://face_preset.json"
const SAVE_PAINT := "user://face_paint.png"
const LIBRARY_DIR := "user://face_library"
const LIBRARY_INDEX := "user://face_library/index.json"
## 图集 rect 来自最初那张 1024² 表情图:face_01=(145,32,219),face_02=(152,290,205)。
## 每个条目用各自的采样矩形,贴图都是项目里的 face_atlas_transparent.png(原图已随模型删掉)。
const BUILTIN_EXPRESSIONS := [
{"id": "builtin:face_01", "name": "预设表情 1", "rect": Rect2(145.0 / 1024.0, 32.0 / 1024.0, 219.0 / 1024.0, 219.0 / 1024.0)},
{"id": "builtin:face_02", "name": "预设表情 2", "rect": Rect2(152.0 / 1024.0, 290.0 / 1024.0, 205.0 / 1024.0, 205.0 / 1024.0)},
]
## 联机同步:非本机玩家主动去要脸的次数与间隔。
## 各端玩家节点出现的先后顺序不保证,头几次请求可能打空,所以要重试。
const FACE_SYNC_TRIES := 5
const FACE_SYNC_INTERVAL := 0.8
## 第一次要之前先等一会儿。实测:主机生成玩家比客户端早几帧,立刻要会打在一个
## 还没建出来的节点上(Godot 报 "Node not found: Main/<id>/FaceRig"),白刷一堆红字。
const FACE_SYNC_INITIAL_DELAY := 1.0
@export_group("接线")
## 面部所在的 MeshInstance3D 的路径(相对本节点)。默认就是 mouse.glb 里的 body_mesh。
## 存路径而不是节点引用:节点引用是场景实例化时由 Godot 解析的,实测这里解析不到(拿到 null),
## 自己在 _ready() 里 get_node_or_null 才稳。
@export var mesh_path: NodePath = ^"../Body/mouse/MouseRig/Skeleton3D/body_mesh"
## 面部是第几个 surface。mouse.glb 里是 1,名字就叫 face
@export var surface_index: int = 1
## 没有存档时用哪张脸
@export var default_preset: Texture2D
## 覆盖第几个 surface。现在 body_mesh 只有一个材质 mat_body,所以是 0。
@export var surface_index: int = 0
@export var default_expression_id: String = "builtin:face_01"
@export_group("画布")
## 画布边长(像素)。玩家画的笔画坐标就在这个空间里
@export var canvas_size: int = 512
## 与两个原始贴图的背景/头部底色完全相同:RGB(137,179,255)。
@export var skin_color: Color = Color(0.537255, 0.701961, 1.0, 1.0)
@export_group("面部 UV")
## 面部孤岛在贴图上的起点(实测自模型)。模型重导后 UV 变了就改这里
@export var face_uv_min: Vector2 = Vector2(0.142, 0.031)
## 孤岛的边长(UV 单位)
@export var face_uv_size: float = 0.214
## 当前预设的 res:// 路径。存档靠它记住「你选的是哪张脸」
var preset_path: String = ""
## 联机时有没有收到过这张脸主人的数据(收到了就不用再重试)
var _got_face := false
@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
var mesh: MeshInstance3D
var material: ShaderMaterial
var painter: FacePainter
var selected_expression_id: String = ""
var selected_expression_name: String = ""
var selected_expression_texture: Texture2D
var _atlas_texture: Texture2D
var _no_base: Texture2D
var _base_uv_min := Vector2.ZERO
var _base_uv_size := Vector2.ONE
var _got_face := false
func _ready() -> void:
@@ -57,76 +61,221 @@ func _ready() -> void:
push_warning("%s 找不到面部 mesh(%s),脸不会有变化" % [get_path(), mesh_path])
return
_atlas_texture = load(ATLAS_PATH) as Texture2D
painter = FacePainter.new(canvas_size)
painter.name = "FacePainter"
add_child(painter)
material = ShaderMaterial.new()
material.shader = load(SHADER_PATH)
var body_material := mesh.mesh.surface_get_material(surface_index) as StandardMaterial3D
if body_material == null:
push_warning("%s 的 surface %d 不是 StandardMaterial3D,身体底色可能不对" % [mesh_path, surface_index])
else:
# 身体底色、粗糙度、金属度都照抄 mat_body —— 脸和身体才可能看起来是同一块皮肤
var body_tex := body_material.albedo_texture
if body_tex != null:
material.set_shader_parameter("body_tex", body_tex)
# 画布和缩略图垫的底色也跟着身体贴图走,省得和模型对不上。
# 导入的贴图是压缩格式,get_pixel 之前必须先解压。
var body_image := body_tex.get_image()
if body_image != null:
if body_image.is_compressed():
body_image.decompress()
if not body_image.is_empty():
var body_rgb := body_image.get_pixel(0, 0)
skin_color = Color(body_rgb.r, body_rgb.g, body_rgb.b, 1.0)
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)
apply_saved()
# 联机时非本机玩家去要脸。单机(OfflineMultiplayerPeer)不用同步
var peer := multiplayer.multiplayer_peer
if peer != null and not (peer is OfflineMultiplayerPeer) and not is_multiplayer_authority():
_pull_remote_face()
# ---------------- 对外接口 ----------------
# ---------------- 表情列表与选择 ----------------
## 换成某张预设脸。只换底图,玩家画的笔画保留 —— 所以可以边挑预设边留着笔迹。
func set_preset(tex: Texture2D, path: String = "") -> void:
if material != null:
material.set_shader_parameter("base_tex", tex)
if not path.is_empty():
preset_path = path
## 返回内置 atlas 表情和本地保存表情,供角色选择页建缩略图。
func get_expression_catalog() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for builtin in BUILTIN_EXPRESSIONS:
var rect: Rect2 = builtin["rect"]
var thumb := AtlasTexture.new()
thumb.atlas = _atlas_texture
thumb.region = Rect2(rect.position * Vector2(1024.0, 1024.0), rect.size * Vector2(1024.0, 1024.0))
thumb.filter_clip = true
entries.append({
"id": builtin["id"], "name": builtin["name"],
"texture": _atlas_texture, "thumbnail": thumb,
"uv_min": rect.position, "uv_size": rect.size, "is_atlas": true,
})
for row in _read_library_index():
var id := str(row.get("id", ""))
var filename := str(row.get("file", ""))
if id.is_empty() or filename.is_empty():
continue
var image := Image.load_from_file("%s/%s" % [LIBRARY_DIR, filename])
if image == null or image.is_empty():
continue
var tex := ImageTexture.create_from_image(image)
entries.append({
"id": id, "name": str(row.get("name", "我的表情")),
"texture": tex, "thumbnail": tex,
"uv_min": Vector2.ZERO, "uv_size": Vector2.ONE, "is_atlas": false,
})
return entries
func clear_paint() -> void:
if painter != null:
## 选择一个内置或本地表情。切换项目时清掉临时笔画,表情项本身是完整表情。
func select_expression(expression_id: String, clear_paint: bool = true) -> bool:
for entry in get_expression_catalog():
if str(entry["id"]) != expression_id:
continue
_apply_expression(entry)
if clear_paint and painter != null:
painter.clear()
return true
return false
## 「自己画」/取消快照需要的透明空白项,不保存为内置表情。
func select_blank(clear_paint: bool = true) -> void:
selected_expression_id = ""
selected_expression_name = "空白"
selected_expression_texture = _no_base_texture()
_base_uv_min = Vector2.ZERO
_base_uv_size = Vector2.ONE
_update_shader_expression()
if clear_paint and painter != null:
painter.clear()
## 存:预设路径 + 笔画层。下次进游戏自动读回来
func _apply_expression(entry: Dictionary) -> void:
selected_expression_id = str(entry["id"])
selected_expression_name = str(entry["name"])
selected_expression_texture = entry["texture"] as Texture2D
_base_uv_min = entry["uv_min"] as Vector2
_base_uv_size = entry["uv_size"] as Vector2
_update_shader_expression()
func _update_shader_expression() -> void:
if material == null:
return
material.set_shader_parameter("base_tex", selected_expression_texture if selected_expression_texture != null else _no_base_texture())
material.set_shader_parameter("base_uv_min", _base_uv_min)
material.set_shader_parameter("base_uv_size", _base_uv_size)
func _no_base_texture() -> Texture2D:
if _no_base == null:
_no_base = ImageTexture.create_from_image(Image.create(2, 2, false, Image.FORMAT_RGBA8))
return _no_base
# ---------------- 本地表情库 ----------------
func save_drawing_as_expression(display_name: String) -> Dictionary:
if painter == null:
return {}
var absolute_dir := ProjectSettings.globalize_path(LIBRARY_DIR)
var dir_error := DirAccess.make_dir_recursive_absolute(absolute_dir)
if dir_error != OK and dir_error != ERR_ALREADY_EXISTS:
push_warning("无法创建本地表情目录:%s" % error_string(dir_error))
return {}
var rows := _read_library_index()
var name := display_name.strip_edges()
if name.is_empty():
name = "我的表情 %d" % (rows.size() + 1)
var id := "user:%d" % Time.get_ticks_usec()
var filename := id.replace(":", "_") + ".png"
var file := FileAccess.open("%s/%s" % [LIBRARY_DIR, filename], FileAccess.WRITE)
if file == null:
push_warning("无法保存本地表情:%s" % filename)
return {}
file.store_buffer(painter.to_png_bytes())
file.close()
rows.append({"id": id, "name": name, "file": filename})
if not _write_library_index(rows):
return {}
if not select_expression(id):
return {}
save()
return {"id": id, "name": name}
func _read_library_index() -> Array:
if not FileAccess.file_exists(LIBRARY_INDEX):
return []
var file := FileAccess.open(LIBRARY_INDEX, FileAccess.READ)
if file == null:
return []
var data: Variant = JSON.parse_string(file.get_as_text())
return data if data is Array else []
func _write_library_index(rows: Array) -> bool:
var file := FileAccess.open(LIBRARY_INDEX, FileAccess.WRITE)
if file == null:
push_warning("无法更新本地表情索引:%s" % LIBRARY_INDEX)
return false
file.store_string(JSON.stringify(rows))
file.close()
return true
# ---------------- 存档 ----------------
func save() -> void:
if painter == null:
return
painter.save_png(SAVE_PAINT)
var f := FileAccess.open(SAVE_PRESET, FileAccess.WRITE)
if f == null:
push_warning("脸部预设存不下来:%s" % SAVE_PRESET)
var file := FileAccess.open(LEGACY_PRESET_SAVE, FileAccess.WRITE)
if file == null:
push_warning("脸部选择存不下来:%s" % LEGACY_PRESET_SAVE)
return
f.store_string(JSON.stringify({"preset": preset_path}))
file.store_string(JSON.stringify({"expression_id": selected_expression_id}))
file.close()
## 读存档;没有(或存的预设已经不在了)就退回 default_preset
func apply_saved() -> void:
var preset: Texture2D = default_preset
var path := default_preset.resource_path if default_preset != null else ""
if FileAccess.file_exists(SAVE_PRESET):
var f := FileAccess.open(SAVE_PRESET, FileAccess.READ)
var data: Variant = JSON.parse_string(f.get_as_text()) if f != null else null
var expression_id := default_expression_id
if FileAccess.file_exists(LEGACY_PRESET_SAVE):
var file := FileAccess.open(LEGACY_PRESET_SAVE, FileAccess.READ)
var data: Variant = JSON.parse_string(file.get_as_text()) if file != null else null
if data is Dictionary:
var saved := str(data.get("preset", ""))
if not saved.is_empty() and ResourceLoader.exists(saved):
preset = load(saved)
path = saved
set_preset(preset, path)
expression_id = str(data.get("expression_id", _legacy_expression_id(str(data.get("preset", "")))))
if expression_id.is_empty():
select_blank(false)
elif not select_expression(expression_id, false):
select_expression(default_expression_id, false)
if painter != null:
painter.load_png(SAVE_PAINT)
func _legacy_expression_id(old_path: String) -> String:
if old_path.is_empty():
return ""
if old_path.ends_with("face_02.png"):
return "builtin:face_02"
if old_path.ends_with("face_01.png"):
return "builtin:face_01"
return default_expression_id
# ---------------- 联机同步 ----------------
# 用「谁缺谁来要」而不是「出生时推」:各端玩家节点出现的先后顺序不保证,
# 推的话很可能推给一个还不存在的节点;而请求总是从已经存在的那一端发出去的。
## 非本机玩家在 _ready 里调,去要这张脸主人的那一份。带重试,因为对端节点可能还没建好。
func _pull_remote_face() -> void:
await get_tree().create_timer(FACE_SYNC_INITIAL_DELAY).timeout
for i in FACE_SYNC_TRIES:
@@ -138,25 +287,39 @@ func _pull_remote_face() -> void:
@rpc("any_peer", "call_remote", "reliable")
func _rpc_request_face() -> void:
# 只有这张脸的主人回应 —— 别人的脸不该由我来发
if not is_multiplayer_authority():
return
var local_png := PackedByteArray()
if selected_expression_id.begins_with("user:") and selected_expression_texture != null:
var image := selected_expression_texture.get_image()
local_png = image.save_png_to_buffer()
_rpc_send_face.rpc_id(
multiplayer.get_remote_sender_id(),
preset_path,
selected_expression_id,
local_png,
painter.to_png_bytes() if painter != null else PackedByteArray(),
)
@rpc("any_peer", "call_remote", "reliable")
func _rpc_send_face(preset: String, png: PackedByteArray) -> void:
# 只认这张脸主人的回应;本机自己的脸不接受覆盖
func _rpc_send_face(expression_id: String, local_png: PackedByteArray, paint_png: PackedByteArray) -> void:
if is_multiplayer_authority() or multiplayer.get_remote_sender_id() != get_multiplayer_authority():
return
_got_face = true
if not preset.is_empty() and ResourceLoader.exists(preset):
set_preset(load(preset), preset)
if png.size() > 0 and painter != null:
if expression_id.is_empty():
select_blank(false)
elif expression_id.begins_with("user:") and local_png.size() > 0:
var img := Image.new()
if img.load_png_from_buffer(png) == OK:
painter.set_image(img)
if img.load_png_from_buffer(local_png) == OK:
selected_expression_id = expression_id
selected_expression_name = "远端玩家表情"
selected_expression_texture = ImageTexture.create_from_image(img)
_base_uv_min = Vector2.ZERO
_base_uv_size = Vector2.ONE
_update_shader_expression()
else:
select_expression(expression_id, false)
if paint_png.size() > 0 and painter != null:
var paint_image := Image.new()
if paint_image.load_png_from_buffer(paint_png) == OK:
painter.set_image(paint_image)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -2,16 +2,16 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://b2yfeejwww5oi"
path="res://.godot/imported/face_00_blank.png-258a9c2a268c4896542abf099fbd9d15.ctex"
uid="uid://cg7dharvu5svr"
path="res://.godot/imported/face_atlas_transparent.png-d56481b0045839c14a1d633b682cc418.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://character/player/face/presets/face_00_blank.png"
dest_files=["res://.godot/imported/face_00_blank.png-258a9c2a268c4896542abf099fbd9d15.ctex"]
source_file="res://character/player/face/presets/face_atlas_transparent.png"
dest_files=["res://.godot/imported/face_atlas_transparent.png-d56481b0045839c14a1d633b682cc418.ctex"]
[params]