From f561e52aa968654ca4a1c5de603e419598647c41 Mon Sep 17 00:00:00 2001 From: sindoring Date: Tue, 7 Jul 2026 11:33:57 +0400 Subject: [PATCH] =?UTF-8?q?=D1=88=D0=B5=D0=B9=D0=B4=D0=B5=D1=80=D1=8B=20?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B1=D0=BE=D1=80=D0=B4=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- RENDER.md | 10 + addons/mpq_extractor/loaders/m2_builder.gd | 17 +- .../loaders/m2_native_animated_builder.gd | 3 +- .../mpq_extractor/loaders/wow_m2_material.gd | 55 ++++- src/native/src/m2_loader.cpp | 193 +++++++++++++++++- .../streaming/streaming_world_loader.gd | 48 ++++- 6 files changed, 320 insertions(+), 6 deletions(-) diff --git a/RENDER.md b/RENDER.md index ee30d7e..a6301a2 100644 --- a/RENDER.md +++ b/RENDER.md @@ -735,6 +735,16 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - WMO window materials such as `MM_ELWYNN_WND_EXT__01.BLP` carry high `MOMT.sidnColor`/emissive values. Emissive is texture-masked and capped for window paths so it brightens the pane pattern instead of flooding the whole quad white. - Runtime WMO cache material refresh is versioned separately from WMO geometry cache. Shader-only WMO material fixes should bump `WMO_MATERIAL_REFRESH_VERSION` instead of forcing a full `wmo_render_v1` rebake. +## 2026-07-07 M2 Billboard Pass + +- Native `M2Loader` now reads `M2CompBone.flags` for billboard bits `0x8/0x10/0x20/0x40` on the static path, not only on the animated path. +- Billboard metadata is resolved per drawn vertex through the skin section bone palette and vertex bone indices. This avoids treating an entire mixed surface as billboard just because the palette contains a billboard bone. +- `M2Builder` stores billboard pivot/mode in `Mesh.ARRAY_CUSTOM0` as RGBA float data. `CUSTOM0.xyz` is the Godot-space bone pivot; `CUSTOM0.w` is the billboard mode: spherical, lock X, lock Y, or lock Z. +- `WowM2Material` applies camera-facing billboarding in the vertex shader for marked vertices. This works with grouped ADT M2 `MultiMesh` batches because the shader uses each instance transform. +- Runtime M2 cache refresh now has `M2_MATERIAL_REFRESH_VERSION`. Old cached `.tscn/.glb` meshes are replaced in memory from raw `.m2` when available, so the billboard/custom-attribute path does not require an immediate cache rebake. +- Verified with `ElwynnGrass1.m2`: native data exposes a billboard batch, the built mesh contains `CUSTOM0`, and the M2 shader compiles with the billboard uniform enabled. +- Remaining parity work: many Elwynn tree canopy M2s do not carry billboard bone flags, so their flat-looking leaves are a separate material/geometry parity issue, not this bone-billboard path. + ## Practical Rule For Future Work If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. diff --git a/addons/mpq_extractor/loaders/m2_builder.gd b/addons/mpq_extractor/loaders/m2_builder.gd index 27cfbb6..99166d9 100644 --- a/addons/mpq_extractor/loaders/m2_builder.gd +++ b/addons/mpq_extractor/loaders/m2_builder.gd @@ -6,6 +6,7 @@ class_name M2Builder const WOW_M2_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_m2_material.gd") +const MATERIAL_FORMAT_VERSION := 1 static var _texture_cache: Dictionary = {} @@ -67,7 +68,8 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: arrays[Mesh.ARRAY_TEX_UV2] = uvs2 arrays[Mesh.ARRAY_INDEX] = batch_indices - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var surface_flags := _apply_billboard_custom_data(arrays, batch, verts.size()) + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays, [], {}, surface_flags) var surf_idx := mesh.get_surface_count() - 1 var mat_def: Dictionary = materials[mat_id] if mat_id >= 0 and mat_id < materials.size() else {} @@ -94,6 +96,8 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: if mesh.get_surface_count() == 0: return root + mesh.set_meta("wow_m2_material_refresh_version", MATERIAL_FORMAT_VERSION) + mesh.set_meta("wow_m2_source_path", model_path) var mi := MeshInstance3D.new() mi.name = "Mesh" @@ -163,6 +167,7 @@ static func _build_material( combiner["vertex_shader_id"] = _m2_vertex_shader_id(texture_count, int(batch.get("shader_id", 0))) combiner["pixel_shader_id"] = _m2_pixel_shader_id(texture_count, int(batch.get("shader_id", 0))) combiner["priority_plane"] = int(batch.get("priority_plane", 0)) + combiner["billboard_enabled"] = bool(batch.get("has_billboard", false)) or int(batch.get("billboard_vertex_count", 0)) > 0 if _is_soft_waterfall_material(model_path, blend_mode, combiner): combiner["effect_alpha_scale"] = 1.12 combiner["effect_alpha_power"] = 0.92 @@ -187,9 +192,19 @@ static func _build_material( mat.set_meta("wow_m2_vertex_shader_id", int(combiner.get("vertex_shader_id", 0))) mat.set_meta("wow_m2_pixel_shader_id", int(combiner.get("pixel_shader_id", 0))) mat.set_meta("wow_priority_plane", int(batch.get("priority_plane", 0))) + mat.set_meta("wow_m2_billboard_enabled", bool(combiner.get("billboard_enabled", false))) + mat.set_meta("wow_m2_billboard_bone_flags", int(batch.get("billboard_bone_flags", 0))) return mat +static func _apply_billboard_custom_data(arrays: Array, batch: Dictionary, vertex_count: int) -> int: + var billboard_data: PackedFloat32Array = batch.get("billboard_data", PackedFloat32Array()) + if billboard_data.size() != vertex_count * 4: + return 0 + arrays[Mesh.ARRAY_CUSTOM0] = billboard_data + return int(Mesh.ARRAY_CUSTOM_RGBA_FLOAT) << int(Mesh.ARRAY_FORMAT_CUSTOM0_SHIFT) + + static func _is_soft_waterfall_material(model_path: String, blend_mode: int, combiner: Dictionary) -> bool: if blend_mode != 2: return false diff --git a/addons/mpq_extractor/loaders/m2_native_animated_builder.gd b/addons/mpq_extractor/loaders/m2_native_animated_builder.gd index 00c2187..044bb6e 100644 --- a/addons/mpq_extractor/loaders/m2_native_animated_builder.gd +++ b/addons/mpq_extractor/loaders/m2_native_animated_builder.gd @@ -72,7 +72,8 @@ static func _build_mesh(data: Dictionary, surfaces: Array, extracted_dir: String arrays[Mesh.ARRAY_TEX_UV2] = uvs2 arrays[Mesh.ARRAY_INDEX] = indices - mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var surface_flags := M2_BUILDER_SCRIPT._apply_billboard_custom_data(arrays, surface, verts.size()) + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays, [], {}, surface_flags) var surface_index := mesh.get_surface_count() - 1 var material_id := int(surface.get("material_id", -1)) var texture_combo_index := int(surface.get("texture_combo_index", -1)) diff --git a/addons/mpq_extractor/loaders/wow_m2_material.gd b/addons/mpq_extractor/loaders/wow_m2_material.gd index 31597fe..96ce9e9 100644 --- a/addons/mpq_extractor/loaders/wow_m2_material.gd +++ b/addons/mpq_extractor/loaders/wow_m2_material.gd @@ -87,6 +87,7 @@ static func build( mat.set_shader_parameter("effect_alpha_scale", combiner.get("effect_alpha_scale", 1.0)) mat.set_shader_parameter("effect_alpha_power", combiner.get("effect_alpha_power", 1.0)) mat.set_shader_parameter("uv_edge_fade_strength", combiner.get("uv_edge_fade_strength", 0.0)) + mat.set_shader_parameter("billboard_enabled", bool(combiner.get("billboard_enabled", false))) mat.set_meta("wow_flags", material_flags) mat.set_meta("wow_blend_mode", blend_mode) mat.set_meta("wow_priority_plane", int(combiner.get("priority_plane", 0))) @@ -94,6 +95,7 @@ static func build( mat.set_meta("wow_texture2_flags", texture2_flags) mat.set_meta("wow_texture3_flags", texture3_flags) mat.set_meta("wow_texture4_flags", texture4_flags) + mat.set_meta("wow_m2_billboard_enabled", bool(combiner.get("billboard_enabled", false))) return mat @@ -228,6 +230,7 @@ uniform vec2 stage3_uv_scroll = vec2(0.0); uniform float effect_alpha_scale = 1.0; uniform float effect_alpha_power = 1.0; uniform float uv_edge_fade_strength = 0.0; +uniform bool billboard_enabled = false; global uniform vec4 wow_ambient_color; global uniform vec4 wow_light_color; @@ -244,8 +247,58 @@ varying vec2 uv_interp; varying vec2 uv2_interp; varying float edge_fade; +vec3 safe_normalize(vec3 value, vec3 fallback) { + float len = length(value); + if (len <= 0.00001) { + return normalize(fallback); + } + return value / len; +} + +vec3 apply_m2_billboard(vec3 vertex, vec3 normal, vec4 custom0, mat4 model_matrix, mat3 model_normal_matrix, mat4 inv_view_matrix, out vec3 billboard_world_normal) { + billboard_world_normal = safe_normalize(model_normal_matrix * normal, normal); + if (!billboard_enabled) { + return vertex; + } + + int mode = int(custom0.w + 0.5); + if (mode <= 0) { + return vertex; + } + + vec3 pivot = custom0.xyz; + vec3 offset = vertex - pivot; + vec3 camera_right = safe_normalize(inv_view_matrix[0].xyz, vec3(1.0, 0.0, 0.0)); + vec3 camera_up = safe_normalize(inv_view_matrix[1].xyz, vec3(0.0, 1.0, 0.0)); + vec3 camera_back = safe_normalize(inv_view_matrix[2].xyz, vec3(0.0, 0.0, 1.0)); + vec3 world_x = camera_right; + vec3 world_y = camera_up; + vec3 world_z = camera_back; + + if (mode == 2) { + world_x = safe_normalize((model_matrix * vec4(1.0, 0.0, 0.0, 0.0)).xyz, camera_right); + world_y = safe_normalize(camera_up - world_x * dot(camera_up, world_x), (model_matrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz); + world_z = safe_normalize(cross(world_x, world_y), camera_back); + } else if (mode == 3) { + world_z = safe_normalize((model_matrix * vec4(0.0, 0.0, 1.0, 0.0)).xyz, camera_back); + world_x = safe_normalize(camera_right - world_z * dot(camera_right, world_z), (model_matrix * vec4(1.0, 0.0, 0.0, 0.0)).xyz); + world_y = safe_normalize(cross(world_z, world_x), camera_up); + } else if (mode == 4) { + world_y = safe_normalize((model_matrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz, camera_up); + world_x = safe_normalize(cross(world_y, camera_back), (model_matrix * vec4(1.0, 0.0, 0.0, 0.0)).xyz); + world_z = safe_normalize(cross(world_x, world_y), camera_back); + } + + vec3 world_pivot = (model_matrix * vec4(pivot, 1.0)).xyz; + vec3 billboard_world_pos = world_pivot + world_x * offset.x + world_y * offset.y + world_z * offset.z; + billboard_world_normal = world_z; + return (inverse(model_matrix) * vec4(billboard_world_pos, 1.0)).xyz; +} + void vertex() { - world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL); + vec3 billboard_normal; + VERTEX = apply_m2_billboard(VERTEX, NORMAL, CUSTOM0, MODEL_MATRIX, MODEL_NORMAL_MATRIX, INV_VIEW_MATRIX, billboard_normal); + world_normal = billboard_normal; world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; uv_interp = UV; diff --git a/src/native/src/m2_loader.cpp b/src/native/src/m2_loader.cpp index 39d1525..e6ef446 100644 --- a/src/native/src/m2_loader.cpp +++ b/src/native/src/m2_loader.cpp @@ -236,6 +236,15 @@ struct SkinTextureUnit { static constexpr uint32_t MAGIC_MD20 = 0x3032444D; // 'MD20' LE static constexpr uint32_t MAGIC_SKIN = 0x4E494B53; // 'SKIN' LE +static constexpr uint32_t M2_BONE_FLAG_SPHERICAL_BILLBOARD = 0x8; +static constexpr uint32_t M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_X = 0x10; +static constexpr uint32_t M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Y = 0x20; +static constexpr uint32_t M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Z = 0x40; +static constexpr uint32_t M2_BONE_BILLBOARD_MASK = + M2_BONE_FLAG_SPHERICAL_BILLBOARD + | M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_X + | M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Y + | M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Z; // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -275,6 +284,89 @@ static Vector3 wow_vec3_to_godot(float x, float y, float z) { return Vector3(x, z, -y); } +static int billboard_mode_from_bone_flags(uint32_t flags) { + if (flags & M2_BONE_FLAG_SPHERICAL_BILLBOARD) return 1; + if (flags & M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_X) return 2; + if (flags & M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Y) return 3; + if (flags & M2_BONE_FLAG_CYLINDRICAL_BILLBOARD_LOCK_Z) return 4; + return 0; +} + +static bool resolve_vertex_billboard( + const M2Vertex *verts, + uint32_t vertex_count, + uint16_t vertex_lookup, + uint16_t global_vertex_index, + const uint8_t *skin_bones, + uint32_t skin_property_count, + const uint16_t *bone_combo_arr, + uint32_t bone_combo_count, + uint16_t bone_start, + const M2CompBone *bones, + uint32_t bone_count, + Vector3 &pivot_out, + int &mode_out, + uint32_t &flags_out) { + if (!verts || !bones || global_vertex_index >= vertex_count) { + return false; + } + + const M2Vertex &vertex = verts[global_vertex_index]; + bool all_weights_zero = true; + for (uint32_t i = 0; i < 4; ++i) { + if (vertex.boneWeights[i] > 0) { + all_weights_zero = false; + break; + } + } + + int best_bone = -1; + uint32_t best_flags = 0; + uint8_t best_weight = 0; + for (uint32_t i = 0; i < 4; ++i) { + uint8_t weight = vertex.boneWeights[i]; + if (!all_weights_zero && weight == 0) { + continue; + } + + int local_bone = (int)vertex.boneIndices[i]; + if (skin_bones && vertex_lookup < skin_property_count) { + local_bone = (int)skin_bones[(uint32_t)vertex_lookup * 4 + i]; + } + + int global_bone = local_bone; + uint32_t lookup_index = (uint32_t)bone_start + (uint32_t)std::max(local_bone, 0); + if (bone_combo_arr && lookup_index < bone_combo_count) { + global_bone = (int)bone_combo_arr[lookup_index]; + } + if (global_bone < 0 || (uint32_t)global_bone >= bone_count) { + continue; + } + + uint32_t flags = bones[global_bone].flags; + if ((flags & M2_BONE_BILLBOARD_MASK) == 0) { + continue; + } + if (best_bone < 0 || weight > best_weight) { + best_bone = global_bone; + best_flags = flags; + best_weight = weight; + } + } + + if (best_bone < 0) { + return false; + } + + mode_out = billboard_mode_from_bone_flags(best_flags); + if (mode_out <= 0) { + return false; + } + pivot_out = wow_vec3_to_godot(bones[best_bone].pivot); + flags_out = best_flags; + return true; +} + static Vector4 normalize_quat(float x, float y, float z, float w) { float len = std::sqrt(x * x + y * y + z * z + w * w); if (len <= 0.000001f) { @@ -643,6 +735,7 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string PackedInt32Array bone_lookup_table; Array batches; Array animated_surfaces; + const M2CompBone *bones = safe_array(buf, hdr.ofsBones, hdr.nBones); const uint16_t *bone_combo_arr = safe_array(buf, hdr.ofsBoneCombos, hdr.nBoneCombos); if (bone_combo_arr) { bone_lookup_table.resize(hdr.nBoneCombos); @@ -691,6 +784,59 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string int idx_count = (int)indices.size() - idx_start; if (idx_count <= 0) continue; + PackedFloat32Array billboard_data; + uint32_t billboard_vertex_count = 0; + uint32_t billboard_flags_seen = 0; + if (verts && bones) { + for (uint32_t t = 0; t + 2 < tri_count; t += 3) { + uint16_t tri_local[3] = { + skin_tri[tri_start + t], + skin_tri[tri_start + t + 2], + skin_tri[tri_start + t + 1], + }; + for (uint32_t corner = 0; corner < 3; ++corner) { + uint16_t vertex_lookup = tri_local[corner]; + if (vertex_lookup >= skin.nIndices) { + continue; + } + uint16_t global_vertex_index = skin_idx[vertex_lookup]; + Vector3 pivot; + int mode = 0; + uint32_t bone_flags = 0; + if (!resolve_vertex_billboard( + verts, + hdr.nVertices, + vertex_lookup, + global_vertex_index, + skin_bones, + skin.nProperties, + bone_combo_arr, + hdr.nBoneCombos, + sm.boneStart, + bones, + hdr.nBones, + pivot, + mode, + bone_flags)) { + continue; + } + if (billboard_data.is_empty()) { + billboard_data.resize(hdr.nVertices * 4); + } + uint32_t dst = (uint32_t)global_vertex_index * 4; + if (dst + 3 >= (uint32_t)billboard_data.size()) { + continue; + } + billboard_data[dst] = pivot.x; + billboard_data[dst + 1] = pivot.y; + billboard_data[dst + 2] = pivot.z; + billboard_data[dst + 3] = (float)mode; + billboard_vertex_count++; + billboard_flags_seen |= (bone_flags & M2_BONE_BILLBOARD_MASK); + } + } + } + Dictionary batch; batch["index_start"] = idx_start; batch["index_count"] = idx_count; @@ -708,12 +854,19 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string batch["skin_flags2"] = (int)tu[u].flags2; batch["bone_count"] = (int)sm.boneCount; batch["bone_combo_index"] = (int)sm.boneStart; + if (billboard_vertex_count > 0) { + batch["has_billboard"] = true; + batch["billboard_vertex_count"] = (int)billboard_vertex_count; + batch["billboard_bone_flags"] = (int)billboard_flags_seen; + batch["billboard_data"] = billboard_data; + } batches.push_back(batch); if (include_animation && verts) { PackedVector3Array surface_vertices; PackedVector3Array surface_normals; PackedVector2Array surface_uvs, surface_uvs2; + PackedFloat32Array surface_billboard_data; PackedInt32Array surface_bones; PackedFloat32Array surface_weights; PackedInt32Array surface_indices; @@ -725,6 +878,8 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string surface_bones.resize(tri_count * 4); surface_weights.resize(tri_count * 4); surface_indices.resize(tri_count); + uint32_t surface_billboard_vertex_count = 0; + uint32_t surface_billboard_flags_seen = 0; for (uint32_t t = 0; t + 2 < tri_count; t += 3) { uint16_t tri_local[3] = { @@ -748,6 +903,36 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string surface_uvs2[expanded_count] = Vector2(v.texCoords2[0], v.texCoords2[1]); surface_indices[expanded_count] = (int)expanded_count; + Vector3 billboard_pivot; + int billboard_mode = 0; + uint32_t billboard_bone_flags = 0; + if (resolve_vertex_billboard( + verts, + hdr.nVertices, + vertex_lookup, + global_vertex_index, + skin_bones, + skin.nProperties, + bone_combo_arr, + hdr.nBoneCombos, + sm.boneStart, + bones, + hdr.nBones, + billboard_pivot, + billboard_mode, + billboard_bone_flags)) { + if (surface_billboard_data.is_empty()) { + surface_billboard_data.resize(tri_count * 4); + } + uint32_t dst = expanded_count * 4; + surface_billboard_data[dst] = billboard_pivot.x; + surface_billboard_data[dst + 1] = billboard_pivot.y; + surface_billboard_data[dst + 2] = billboard_pivot.z; + surface_billboard_data[dst + 3] = (float)billboard_mode; + surface_billboard_vertex_count++; + surface_billboard_flags_seen |= (billboard_bone_flags & M2_BONE_BILLBOARD_MASK); + } + uint32_t weight_sum = 0; for (uint32_t j = 0; j < 4; ++j) { weight_sum += (uint32_t)v.boneWeights[j]; @@ -786,6 +971,13 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string surface["normals"] = surface_normals; surface["uvs"] = surface_uvs; surface["uvs2"] = surface_uvs2; + if (surface_billboard_vertex_count > 0) { + surface_billboard_data.resize(expanded_count * 4); + surface["has_billboard"] = true; + surface["billboard_vertex_count"] = (int)surface_billboard_vertex_count; + surface["billboard_bone_flags"] = (int)surface_billboard_flags_seen; + surface["billboard_data"] = surface_billboard_data; + } surface["bones"] = surface_bones; surface["weights"] = surface_weights; surface["indices"] = surface_indices; @@ -822,7 +1014,6 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string uint32_t animation_activity_score = 0; if (include_animation) { const M2Sequence *seqs = safe_array(buf, hdr.ofsAnimations, hdr.nAnimations); - const M2CompBone *bones = safe_array(buf, hdr.ofsBones, hdr.nBones); if (seqs && hdr.nAnimations > 0) { std::string lower_path = path; std::transform(lower_path.begin(), lower_path.end(), lower_path.begin(), [](unsigned char c) { return (char)std::tolower(c); }); diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index fae7284..a465065 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -15,6 +15,7 @@ const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native const REQUIRED_BAKED_TILE_FORMAT_VERSION := 4 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 +const M2_MATERIAL_REFRESH_VERSION := 1 const WMO_MATERIAL_REFRESH_VERSION := 3 const TILE_SIZE := 533.33333 @@ -4400,9 +4401,46 @@ func _request_m2_mesh_load(normalized_rel: String) -> void: func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh: if mesh == null: return null + if int(mesh.get_meta("wow_m2_material_refresh_version", 0)) >= M2_MATERIAL_REFRESH_VERSION: + return mesh + var rebuilt := _rebuild_m2_mesh_from_raw(normalized_rel) + if rebuilt != null: + return rebuilt + mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION) return mesh +func _prepare_m2_node_for_runtime(normalized_rel: String, root: Node3D) -> void: + if root == null: + return + for mesh_instance in _find_mesh_instances_recursive(root): + if mesh_instance == null or mesh_instance.mesh == null: + continue + var prepared := _prepare_m2_mesh_for_runtime(normalized_rel, mesh_instance.mesh) + if prepared != null: + mesh_instance.mesh = prepared + + +func _rebuild_m2_mesh_from_raw(normalized_rel: String) -> Mesh: + if normalized_rel.is_empty() or not ClassDB.class_exists("M2Loader"): + return null + var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(normalized_rel)) + if not FileAccess.file_exists(abs_path): + return null + var loader = ClassDB.instantiate("M2Loader") + if loader == null: + return null + var data: Dictionary = loader.call("load_m2", abs_path) + if data.is_empty(): + return null + var prototype: Node3D = M2_BUILDER_SCRIPT.build(data, extracted_dir) + if prototype == null: + return null + var rebuilt := _find_first_mesh_recursive(prototype) + prototype.free() + return rebuilt + + func _extract_first_mesh_from_m2_resource(resource: Resource) -> Mesh: if resource is Mesh: return resource as Mesh @@ -4572,7 +4610,9 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: if normalized_rel.is_empty(): return null if _m2_scene_cache.has(normalized_rel): - return _m2_scene_cache[normalized_rel] + var cached: Node3D = _m2_scene_cache[normalized_rel] + _prepare_m2_node_for_runtime(normalized_rel, cached) + return cached if _m2_missing_cache.has(normalized_rel): return null @@ -4586,6 +4626,7 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: if resource is PackedScene: var node = (resource as PackedScene).instantiate() if node is Node3D: + _prepare_m2_node_for_runtime(normalized_rel, node as Node3D) _m2_scene_cache[normalized_rel] = node as Node3D return node as Node3D break @@ -4768,7 +4809,9 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: if normalized_rel.is_empty(): return null if _m2_scene_cache.has(normalized_rel): - return _m2_scene_cache[normalized_rel] + var cached: Node3D = _m2_scene_cache[normalized_rel] + _prepare_m2_node_for_runtime(normalized_rel, cached) + return cached for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn"]): if not ResourceLoader.exists(cache_res_path): @@ -4777,6 +4820,7 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: if resource is PackedScene: var node = (resource as PackedScene).instantiate() if node is Node3D: + _prepare_m2_node_for_runtime(normalized_rel, node as Node3D) _m2_scene_cache[normalized_rel] = node as Node3D return node as Node3D return null