326 lines
12 KiB
GDScript
326 lines
12 KiB
GDScript
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
|
||
## 与两个原始贴图的背景/头部底色完全相同:RGB(137,179,255)。
|
||
@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
|
||
|
||
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("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()
|
||
|
||
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 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 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)
|