## Converts raw WMOLoader data into a Godot Node3D scene tree. ## Usage: ## var data = WMOLoader.new().load_wmo(abs_path) ## var node = WMOBuilder.build(data, "res://data/extracted") ## add_child(node) class_name WMOBuilder const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd") const M2_RIGHT_YAW_OFFSET := PI * 0.5 const BUILD_OCCLUDERS := false const OCCLUDER_MIN_TRIANGLES := 16 static var _texture_cache: Dictionary = {} # rel_path → Node3D prototype (built once, duplicated per usage) static var _m2_prototype_cache: Dictionary = {} # rel_path → true (resolved missing — skip) static var _m2_missing_cache: Dictionary = {} # Bake scripts should call this before SceneTree.quit() to release cached # Node3D prototypes (Node-derived objects aren't RefCounted, so the static # dict alone won't free them at exit). static func clear_caches() -> void: for proto in _m2_prototype_cache.values(): if is_instance_valid(proto): (proto as Node).free() _m2_prototype_cache.clear() _m2_missing_cache.clear() _texture_cache.clear() WOW_LIQUID_MATERIAL.clear_cache() # Returns a Node3D containing one MeshInstance3D per WMO group. static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: var root := Node3D.new() root.name = "WMO" if not data.has("groups"): return root var textures: PackedStringArray = data.get("textures", PackedStringArray()) var materials: Array = data.get("materials", []) # Build Godot materials from WMO material definitions var godot_materials: Array[StandardMaterial3D] = [] for mat_def in materials: godot_materials.append(_build_material(mat_def, textures, extracted_dir)) # Build one MeshInstance3D per group, plus optional liquid mesh as sibling var groups: Array = data.get("groups", []) var occluder_root: Node3D = null for gi in groups.size(): var g: Dictionary = groups[gi] if g.is_empty(): continue var mesh_inst := _build_group_mesh(g, godot_materials) mesh_inst.name = "Group_%d" % gi root.add_child(mesh_inst) if BUILD_OCCLUDERS: var occluder_inst := _build_group_occluder(g, materials) if occluder_inst != null: if occluder_root == null: occluder_root = Node3D.new() occluder_root.name = "Occluders" root.add_child(occluder_root) occluder_inst.name = "Group_%d_Occluder" % gi occluder_root.add_child(occluder_inst) var liquid_dict: Variant = g.get("liquid", null) if liquid_dict is Dictionary and not (liquid_dict as Dictionary).is_empty(): var liquid_inst := _build_group_liquid(liquid_dict) if liquid_inst != null: liquid_inst.name = "Group_%d_Liquid" % gi root.add_child(liquid_inst) # Doodad set 0 ($DefaultGlobal) — global decorations always active. # Non-default sets (Day/Night/etc) are not baked yet — see TODO below. _build_default_doodads(root, data, extracted_dir) return root # Instantiates M2 doodads for set 0 as one MultiMesh per M2 path. static func _build_default_doodads(root: Node3D, data: Dictionary, extracted_dir: String) -> void: var sets: Array = data.get("doodad_sets", []) var placements: Array = data.get("doodad_placements", []) if placements.is_empty() or sets.is_empty(): return var first_set: Dictionary = sets[0] var start: int = int(first_set.get("start", 0)) var count: int = int(first_set.get("count", 0)) if count <= 0: return var groups: Dictionary = {} var end: int = mini(start + count, placements.size()) for i in range(start, end): var p: Dictionary = placements[i] var rel_path: String = str(p.get("name", "")).replace("\\", "/").to_lower() if rel_path.is_empty(): continue # WoW 3.3.5 stores .mdx/.mdl in MODN; the actual file on disk is .m2. if rel_path.ends_with(".mdx") or rel_path.ends_with(".mdl"): rel_path = rel_path.get_basename() + ".m2" var pos: Vector3 = p.get("pos", Vector3.ZERO) var rot_q: Quaternion = p.get("rot", Quaternion.IDENTITY) var scl: float = float(p.get("scale", 1.0)) var basis := (Basis(rot_q) * Basis(Vector3.UP, M2_RIGHT_YAW_OFFSET)).scaled(Vector3.ONE * maxf(scl, 0.001)) var xform := Transform3D(basis, pos) if not groups.has(rel_path): groups[rel_path] = [] (groups[rel_path] as Array).append(xform) if groups.is_empty(): return var doodad_root := Node3D.new() doodad_root.name = "Doodads" root.add_child(doodad_root) for rel_path in groups.keys(): var mesh := _get_or_load_m2_mesh(rel_path, extracted_dir) if mesh == null: continue var transforms: Array = groups[rel_path] var mm := MultiMesh.new() mm.transform_format = MultiMesh.TRANSFORM_3D mm.mesh = mesh mm.instance_count = transforms.size() for i in transforms.size(): mm.set_instance_transform(i, transforms[i]) var inst := MultiMeshInstance3D.new() inst.name = rel_path.get_file().get_basename() inst.multimesh = mm inst.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF doodad_root.add_child(inst) if doodad_root.get_child_count() == 0: doodad_root.queue_free() static func _get_or_load_m2_mesh(rel_path: String, extracted_dir: String) -> Mesh: var prototype: Node3D = _get_or_load_m2_prototype(rel_path, extracted_dir) if prototype == null: return null for child in prototype.get_children(): if child is MeshInstance3D: return (child as MeshInstance3D).mesh return null static func _get_or_load_m2_prototype(rel_path: String, extracted_dir: String) -> Node3D: if _m2_prototype_cache.has(rel_path): return _m2_prototype_cache[rel_path] if _m2_missing_cache.has(rel_path): return null var prototype: Node3D = _build_raw_m2_prototype(rel_path, extracted_dir) if prototype != null: _m2_prototype_cache[rel_path] = prototype return prototype # Fall back to pre-baked cache when the raw M2 is unavailable. var cache_paths := [ "res://data/cache/m2_glb".path_join(rel_path.get_basename() + ".tscn"), "res://data/cache/m2_glb".path_join(rel_path.get_basename() + ".glb"), ] for cache_path in cache_paths: if not ResourceLoader.exists(cache_path): continue var resource: Resource = load(cache_path) if resource is PackedScene: var node = (resource as PackedScene).instantiate() if node is Node3D: _m2_prototype_cache[rel_path] = node as Node3D return node as Node3D break _m2_missing_cache[rel_path] = true return null static func _build_raw_m2_prototype(rel_path: String, extracted_dir: String) -> Node3D: if not ClassDB.class_exists("M2Loader"): return null var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path)) if not FileAccess.file_exists(abs_path): return null var loader = ClassDB.instantiate("M2Loader") var m2_data: Dictionary = loader.call("load_m2", abs_path) if m2_data.is_empty() or (m2_data.get("vertices", PackedVector3Array()) as PackedVector3Array).is_empty(): return null var prototype: Node3D = M2_BUILDER_SCRIPT.build(m2_data, extracted_dir) return prototype static func _build_group_mesh( g: Dictionary, godot_mats: Array[StandardMaterial3D]) -> MeshInstance3D: var verts: PackedVector3Array = g.get("vertices", PackedVector3Array()) var normals: PackedVector3Array = g.get("normals", PackedVector3Array()) var uvs: PackedVector2Array = g.get("uvs", PackedVector2Array()) var colors: PackedColorArray = g.get("colors", PackedColorArray()) var indices: PackedInt32Array = g.get("indices", PackedInt32Array()) var batches: Array = g.get("batches", []) var mesh := ArrayMesh.new() if verts.is_empty() or indices.is_empty(): var empty := MeshInstance3D.new() empty.mesh = mesh empty.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF return empty if batches.is_empty(): batches = [{ "index_start": 0, "index_count": indices.size(), "material_id": -1, }] for batch_variant in batches: if not (batch_variant is Dictionary): continue var batch: Dictionary = batch_variant var idx_start: int = int(batch.get("index_start", 0)) var idx_count: int = int(batch.get("index_count", 0)) var mat_id: int = int(batch.get("material_id", -1)) if idx_count <= 0 or idx_start >= indices.size(): continue var batch_indices := PackedInt32Array() for i in range(idx_start, mini(idx_start + idx_count, indices.size()), 3): if i + 2 >= indices.size(): break # Reverse winding for the current local basis conversion. batch_indices.append(indices[i + 0]) batch_indices.append(indices[i + 2]) batch_indices.append(indices[i + 1]) if batch_indices.is_empty(): continue var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = verts if normals.size() == verts.size(): arrays[Mesh.ARRAY_NORMAL] = normals if uvs.size() == verts.size(): arrays[Mesh.ARRAY_TEX_UV] = uvs if colors.size() == verts.size(): arrays[Mesh.ARRAY_COLOR] = colors arrays[Mesh.ARRAY_INDEX] = batch_indices mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) var surf_idx := mesh.get_surface_count() - 1 if mat_id >= 0 and mat_id < godot_mats.size(): mesh.surface_set_material(surf_idx, godot_mats[mat_id]) var mi := MeshInstance3D.new() mi.mesh = mesh mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF return mi static func _build_group_occluder(g: Dictionary, material_defs: Array) -> OccluderInstance3D: var verts: PackedVector3Array = g.get("vertices", PackedVector3Array()) var indices: PackedInt32Array = g.get("indices", PackedInt32Array()) if verts.is_empty() or indices.is_empty(): return null var batches: Array = g.get("batches", []) var occluder_indices := PackedInt32Array() if batches.is_empty(): for i in range(0, indices.size(), 3): if i + 2 >= indices.size(): break occluder_indices.append(indices[i + 0]) occluder_indices.append(indices[i + 2]) occluder_indices.append(indices[i + 1]) else: for batch_variant in batches: if not (batch_variant is Dictionary): continue var batch: Dictionary = batch_variant if not _material_can_occlude(int(batch.get("material_id", -1)), material_defs): continue var idx_start: int = int(batch.get("index_start", 0)) var idx_count: int = int(batch.get("index_count", 0)) if idx_count <= 0 or idx_start >= indices.size(): continue for i in range(idx_start, mini(idx_start + idx_count, indices.size()), 3): if i + 2 >= indices.size(): break occluder_indices.append(indices[i + 0]) occluder_indices.append(indices[i + 2]) occluder_indices.append(indices[i + 1]) if occluder_indices.size() < OCCLUDER_MIN_TRIANGLES * 3: return null var occluder := ArrayOccluder3D.new() occluder.set_arrays(verts, occluder_indices) var inst := OccluderInstance3D.new() inst.set_occluder(occluder) return inst static func _material_can_occlude(mat_id: int, material_defs: Array) -> bool: if mat_id < 0: return true if mat_id >= material_defs.size(): return false var mat_def: Variant = material_defs[mat_id] if not (mat_def is Dictionary): return false return int((mat_def as Dictionary).get("blend_mode", 0)) == 0 static func _build_material( mat_def: Dictionary, textures: PackedStringArray, extracted_dir: String = "") -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.cull_mode = BaseMaterial3D.CULL_DISABLED mat.roughness = 0.85 mat.metallic = 0.0 mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED mat.vertex_color_use_as_albedo = false mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, true) var blend_mode: int = mat_def.get("blend_mode", 0) match blend_mode: 0: mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED 1: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR _: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA # Texture is loaded later by the scene assembler (needs BLP→Image conversion) # Store the path as metadata for the assembler to pick up var tex0_id: int = mat_def.get("texture0", -1) if tex0_id >= 0 and tex0_id < textures.size(): var tex_path: String = str(textures[tex0_id]).replace("\\", "/") mat.set_meta("texture0_path", tex_path) var tex := _load_texture(tex_path, extracted_dir) if tex: mat.albedo_texture = tex mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, true) mat.set_meta("wow_flags", mat_def.get("flags", 0)) mat.set_meta("wow_shader", mat_def.get("shader", 0)) return mat # WMO MLIQ → MeshInstance3D in WMO-local coordinates. # Corner & vertex grid are in WMO-local (Z-up); convert to Godot Y-up using the # same basis change as the WMO geometry: (x, y, z)_local → (-y, z, -x)_godot. static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D: var xverts: int = int(liquid.get("xverts", 0)) var yverts: int = int(liquid.get("yverts", 0)) var xtiles: int = int(liquid.get("xtiles", 0)) var ytiles: int = int(liquid.get("ytiles", 0)) if xverts <= 1 or yverts <= 1 or xtiles <= 0 or ytiles <= 0: return null var corner: Vector3 = liquid.get("corner", Vector3.ZERO) var unit: float = float(liquid.get("unit_size", 4.1666666)) var heights: PackedFloat32Array = liquid.get("heights", PackedFloat32Array()) var tiles: PackedByteArray = liquid.get("tiles", PackedByteArray()) if heights.size() < xverts * yverts: return null var verts := PackedVector3Array() var nrms := PackedVector3Array() var uvs := PackedVector2Array() var indices := PackedInt32Array() # Pre-compute Godot-space position for each grid vertex. # WMO local (lx, ly, lz) → Godot (-ly, lz, -lx). var pos := PackedVector3Array() pos.resize(xverts * yverts) for vy in yverts: for vx in xverts: var lx := corner.x + float(vx) * unit var ly := corner.y + float(vy) * unit var lz := heights[vy * xverts + vx] pos[vy * xverts + vx] = Vector3(-ly, lz, -lx) for ty in ytiles: for tx in xtiles: var flag: int = 0 if tiles.size() > ty * xtiles + tx: flag = int(tiles[ty * xtiles + tx]) # wowdev: high bit (0x80) marks "don't render". Some sources also # treat 0x0F lower-nibble as "no liquid" — skip both cases. if (flag & 0x80) != 0: continue if (flag & 0x0F) == 0x0F: continue var i00 := ty * xverts + tx var i10 := ty * xverts + tx + 1 var i01 := (ty + 1) * xverts + tx var i11 := (ty + 1) * xverts + tx + 1 var base := verts.size() verts.append(pos[i00]) verts.append(pos[i10]) verts.append(pos[i11]) verts.append(pos[i01]) nrms.append(Vector3.UP) nrms.append(Vector3.UP) nrms.append(Vector3.UP) nrms.append(Vector3.UP) uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles)))) uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles)))) uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles)))) uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles)))) # Two triangles, winding chosen to face up after the (-y, z, -x) flip. indices.append(base + 0) indices.append(base + 1) indices.append(base + 2) indices.append(base + 0) indices.append(base + 2) indices.append(base + 3) if verts.is_empty() or indices.is_empty(): return null var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = verts arrays[Mesh.ARRAY_NORMAL] = nrms arrays[Mesh.ARRAY_TEX_UV] = uvs arrays[Mesh.ARRAY_INDEX] = indices var mesh := ArrayMesh.new() mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) mesh.surface_set_material(0, _wmo_liquid_material(int(liquid.get("material_id", 0)))) var mi := MeshInstance3D.new() mi.mesh = mesh mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF return mi static func _wmo_liquid_material(material_id: int) -> Material: return WOW_LIQUID_MATERIAL.build_wmo(material_id) static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D: if rel_path.is_empty() or extracted_dir.is_empty(): return null var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path.replace("\\", "/"))) if _texture_cache.has(abs_path): return _texture_cache[abs_path] if not ClassDB.class_exists("BLPLoader"): return null var loader = ClassDB.instantiate("BLPLoader") if loader == null: return null var img: Image = loader.call("load_image", abs_path) if img == null or img.is_empty(): _texture_cache[abs_path] = null return null img.generate_mipmaps() var tex := ImageTexture.create_from_image(img) _texture_cache[abs_path] = tex return tex