243 lines
8.7 KiB
GDScript
243 lines
8.7 KiB
GDScript
## 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
|