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)