first commit

This commit is contained in:
2026-04-20 20:25:10 +04:00
commit 1fe2a72ef1
1926 changed files with 156731 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
## 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