Files
2026-10-10 16:07:53 +08:00

319 lines
12 KiB
GDScript
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.
class_name FaceRig
extends Node
## 角色脸部:body_mesh 现在**只有一个材质**,所以这里不再换材质,
## 而是把 mat_body 换成「全身 shader」:身体照常取它的 BaseColor,
## 只在脸部的 UV 矩形内叠加表情贴图和玩家手绘。
const SHADER_PATH := "res://character/player/face/face_paint.gdshader"
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
const FACE_SYNC_INITIAL_DELAY := 1.0
@export_group("接线")
@export var mesh_path: NodePath = ^"../Body/mouse/MouseRig/Skeleton3D/body_mesh"
## 覆盖第几个 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
## 与模型身体贴图的底色一致(FaceRig 启动时会从 mat_body 的 BaseColor 左上角自动读出来)。
## 只给画布和缩略图垫底用,不参与 3D 着色。
@export var skin_color: Color = Color(0.537255, 0.701961, 1.0, 1.0)
## 表情画在哪 / 多大 / 正不正,全部写在 face_paint.gdshader 顶部的「调表情就改这一段」里
## (face_center / face_size / face_flip_y)。**FaceRig 故意不传这三个 uniform**,
## 免得脚本里的值把 shader 里调好的值盖掉。
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:
mesh = get_node_or_null(mesh_path) as MeshInstance3D
if mesh == null:
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("paint_tex", painter.texture)
mesh.set_surface_override_material(surface_index, material)
apply_saved()
var peer := multiplayer.multiplayer_peer
if peer != null and not (peer is OfflineMultiplayerPeer) and not is_multiplayer_authority():
_pull_remote_face()
# ---------------- 表情列表与选择 ----------------
## 返回内置 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 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 entry_name := display_name.strip_edges()
if entry_name.is_empty():
entry_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": entry_name, "file": filename})
if not _write_library_index(rows):
return {}
if not select_expression(id):
return {}
save()
return {"id": id, "name": entry_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 file := FileAccess.open(LEGACY_PRESET_SAVE, FileAccess.WRITE)
if file == null:
push_warning("脸部选择存不下来:%s" % LEGACY_PRESET_SAVE)
return
file.store_string(JSON.stringify({"expression_id": selected_expression_id}))
file.close()
func apply_saved() -> void:
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:
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
# ---------------- 联机同步 ----------------
func _pull_remote_face() -> void:
await get_tree().create_timer(FACE_SYNC_INITIAL_DELAY).timeout
for i in FACE_SYNC_TRIES:
if not is_inside_tree() or _got_face:
return
_rpc_request_face.rpc_id(get_multiplayer_authority())
await get_tree().create_timer(FACE_SYNC_INTERVAL).timeout
@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(),
selected_expression_id,
local_png,
painter.to_png_bytes() if painter != null else PackedByteArray(),
)
@rpc("any_peer", "call_remote", "reliable")
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 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(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)