Files
open-wc/scenes/character_texture_compositor.gd
T
2026-04-20 20:26:14 +04:00

243 lines
8.7 KiB
GDScript
Raw 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.
## CharacterTextureCompositor
##
## Runtime skin-texture compositing for WoW 3.3.5a character models.
##
## Attach as a child of the character's root node (next to
## CharacterGeosetController). Set [member textures_dir] to the
## {ModelName}_textures/ folder that was exported alongside the GLB.
##
## UV layout of the 512x512 WoW character skin texture:
## (0, 0) 256x128 NakedTorsoSkin bra area
## (0, 128) 256x128 NakedPelvisSkin panties area
## (0, 320) 256x64 FaceUpper forehead / upper face
## (0, 384) 256x128 FaceLower eyes, mouth, nose
@tool
extends Node
# ── Layer paste positions (pixels, top-left origin) ─────────────────────────
const NAKED_TORSO_POS := Vector2i(0, 0)
const NAKED_PELVIS_POS := Vector2i(0, 128)
const FACE_UPPER_POS := Vector2i(0, 320)
const FACE_LOWER_POS := Vector2i(0, 384)
# ── Exports ──────────────────────────────────────────────────────────────────
## Path to the {ModelName}_textures/ folder.
## Example: "res://resources/characters/BloodElf/Female/BloodElfFemale_textures"
@export var textures_dir: String = "" : set = _set_textures_dir
## Skin colour index (selects skin_XX.png / naked_torso_XX.png /
## naked_pelvis_XX.png).
@export var skin_color: int = 0 : set = _set_skin_color
## Face style index (first part of face_lower_SS_CC.png filename).
@export var face_style: int = 0 : set = _set_face_style
# ── Internal state ───────────────────────────────────────────────────────────
# MeshInstance3D surfaces to override: Array of {node, surface_idx}
var _skin_surfaces: Array = []
# True once _ready has run (prevents setters from firing before init)
var _ready_done := false
func _ready() -> void:
_ready_done = true
if textures_dir.is_empty():
_auto_detect_textures_dir()
_scan_skin_surfaces()
_recomposite()
# ── Setters ──────────────────────────────────────────────────────────────────
func _set_textures_dir(v: String) -> void:
textures_dir = v
if _ready_done:
_scan_skin_surfaces()
_recomposite()
func _set_skin_color(v: int) -> void:
skin_color = v
if _ready_done: _recomposite()
func _set_face_style(v: int) -> void:
face_style = v
if _ready_done: _recomposite()
# ── Auto-detection ───────────────────────────────────────────────────────────
func _auto_detect_textures_dir() -> void:
# Convention: GLB is at res://resources/characters/Race/Gender/Model.glb
# Textures: same folder / Model_textures/
# We walk the scene tree to find the first MeshInstance3D and infer from
# the mesh resource path.
var root := get_parent() if get_parent() else get_tree().current_scene
for mesh_inst in _iter_mesh_instances(root):
var mesh := mesh_inst.mesh
if mesh == null:
continue
var rpath := mesh.resource_path # e.g. res://resources/.../BloodElfFemale.glb::Mesh_0
if rpath.is_empty():
continue
# Extract the GLB path (everything before "::")
var glb_path := rpath.get_slice("::", 0)
var dir := glb_path.get_base_dir()
var stem := glb_path.get_file().get_basename()
var candidate := dir.path_join(stem + "_textures")
if DirAccess.dir_exists_absolute(candidate):
textures_dir = candidate
print("CharacterTextureCompositor: auto-detected textures_dir = ", textures_dir)
return
push_warning("CharacterTextureCompositor: could not auto-detect textures_dir; set it manually.")
# ── Surface discovery ────────────────────────────────────────────────────────
func _scan_skin_surfaces() -> void:
_skin_surfaces.clear()
var root := get_parent() if get_parent() else self
for mesh_inst in _iter_mesh_instances(root):
var mesh := mesh_inst.mesh
if mesh == null:
continue
for si in range(mesh.get_surface_count()):
if _is_skin_surface(mesh_inst, si):
_skin_surfaces.append({"node": mesh_inst, "surface": si})
## A surface is a "skin surface" if:
## - its active material is a StandardMaterial3D (not ShaderMaterial = eye glow)
## - AND its albedo texture is 512x512 (the composited body skin)
func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
# Prefer surface override, fall back to mesh material
var mat := mesh_inst.get_surface_override_material(si)
if mat == null:
mat = mesh_inst.mesh.surface_get_material(si)
if not (mat is StandardMaterial3D):
return false
var std := mat as StandardMaterial3D
var tex := std.albedo_texture
if tex == null:
return false
# Skin texture is 512x512; hair/other textures are smaller
var img := tex.get_image()
if img == null:
return false
return img.get_width() == 512 and img.get_height() == 512
# ── Compositing ──────────────────────────────────────────────────────────────
func _recomposite() -> void:
if textures_dir.is_empty() or _skin_surfaces.is_empty():
return
var skin_tex := _build_skin_texture()
if skin_tex == null:
return
_apply_texture(skin_tex)
func _build_skin_texture() -> ImageTexture:
# 1. Base skin
var base := _load_layer("skin_%02d.png" % skin_color)
if base == null:
push_warning("CharacterTextureCompositor: missing skin_%02d.png" % skin_color)
return null
# 2. Naked overlays (underwear)
_blit(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS)
_blit(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)
# 3. Face
_blit(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS)
_blit(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS)
return ImageTexture.create_from_image(base)
func _apply_texture(tex: ImageTexture) -> void:
for entry in _skin_surfaces:
var mesh_inst: MeshInstance3D = entry["node"]
var si: int = entry["surface"]
if not is_instance_valid(mesh_inst):
continue
# Clone the existing material so we don't mutate the shared resource
var orig_mat := mesh_inst.get_surface_override_material(si)
if orig_mat == null:
orig_mat = mesh_inst.mesh.surface_get_material(si)
if not (orig_mat is StandardMaterial3D):
continue
var mat := orig_mat.duplicate() as StandardMaterial3D
mat.albedo_texture = tex
mesh_inst.set_surface_override_material(si, mat)
# ── Helpers ───────────────────────────────────────────────────────────────────
func _load_layer(filename: String) -> Image:
var path := textures_dir.path_join(filename)
if not FileAccess.file_exists(path):
return null
var img := Image.load_from_file(path)
return img
func _blit(dst: Image, src: Image, pos: Vector2i) -> void:
if src == null:
return
dst.blend_rect(src, Rect2i(Vector2i.ZERO, src.get_size()), pos)
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
_collect_meshes(root, result)
return result
func _collect_meshes(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_meshes(child, result)
# ── Public API ────────────────────────────────────────────────────────────────
## Force a full rescan + recomposite (call after swapping the character GLB).
func refresh() -> void:
_scan_skin_surfaces()
_recomposite()
## Returns the number of skin surfaces found (useful for debugging).
func get_skin_surface_count() -> int:
return _skin_surfaces.size()
## Returns how many skin colour variants exist (i.e. max valid skin_color + 1).
func get_skin_color_count() -> int:
if textures_dir.is_empty():
return 0
var n := 0
while FileAccess.file_exists(textures_dir.path_join("skin_%02d.png" % n)):
n += 1
return n
## Returns how many face styles exist (i.e. max valid face_style + 1).
func get_face_style_count() -> int:
if textures_dir.is_empty():
return 0
var n := 0
while FileAccess.file_exists(textures_dir.path_join("face_lower_%02d_00.png" % n)):
n += 1
return n