## 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