Files
open-wc/addons/mpq_extractor/loaders/wmo_builder.gd
T

485 lines
16 KiB
GDScript

## 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 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()
_wmo_liquid_material_cache.clear()
# 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
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_meta("wow_flags", mat_def.get("flags", 0))
mat.set_meta("wow_shader", mat_def.get("shader", 0))
return mat
static var _wmo_liquid_material_cache: Dictionary = {}
# 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 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])
# 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_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) -> StandardMaterial3D:
if _wmo_liquid_material_cache.has(material_id):
return _wmo_liquid_material_cache[material_id]
var mat := StandardMaterial3D.new()
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
# Default: bluish water. Without DBC liquid_type lookup we can't pick magma/slime
# precisely yet — material_id from MLIQ is the WMO MOMT slot, not the DBC id.
mat.albedo_color = Color(0.2, 0.4, 0.7, 0.6)
_wmo_liquid_material_cache[material_id] = mat
return mat
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