diff --git a/.gitignore b/.gitignore index 0b7cb34..ebd2f0d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,11 @@ # Offline WMO render cache /data/cache/wmo_render_v1/ +/data/cache/terrain_splat_v1/ +/data/cache/terrain_control_splat_v1/ +/data/cache/terrain_control_splat_v2/ +/data/cache/terrain_control_splat_v3/ +/data/cache/splat_progress/ # Root cache dir (генерируется скриптом) /cache/ diff --git a/addons/mpq_extractor/loaders/adt_builder.gd b/addons/mpq_extractor/loaders/adt_builder.gd index 58b1c30..4647338 100644 --- a/addons/mpq_extractor/loaders/adt_builder.gd +++ b/addons/mpq_extractor/loaders/adt_builder.gd @@ -9,6 +9,7 @@ const IGNORE_TERRAIN_HOLES := false const USE_GEOMETRY_NORMALS := true static var _terrain_shader: Shader +static var _control_splat_shader: Shader static var _single_texture_shader: Shader static var _empty_alpha_texture: Texture2D static var _native_adt_baker: Object @@ -110,6 +111,35 @@ static func build_baked_tile_render_payload( return _build_baked_tile_payload(data, image_cache, extracted_dir, lod, texture_size) +static func build_control_splat_tile_render_payload( + data: Dictionary, + image_cache: Dictionary, + extracted_dir: String = "", + lod: int = 0, + texture_size: int = 256) -> Dictionary: + return _build_control_splat_tile_payload(data, image_cache, extracted_dir, lod, texture_size) + + +static func apply_control_splat_material( + mesh: Mesh, + texture_images: Array, + alpha_atlas: Texture2D, + layer_index_map: Texture2D) -> Mesh: + + if mesh == null or texture_images.is_empty() or alpha_atlas == null or layer_index_map == null: + return mesh + var texture_array := _build_terrain_texture_array_from_images(texture_images) + if texture_array == null: + return mesh + var array_mesh := mesh as ArrayMesh + if array_mesh == null: + return mesh + array_mesh.surface_set_material( + 0, + _build_control_splat_material(texture_array, alpha_atlas, layer_index_map)) + return array_mesh + + static func build_baked_tile_render_payload_pair( data: Dictionary, image_cache: Dictionary, @@ -128,6 +158,35 @@ static func build_baked_tile_render_payload_pair( coarse_texture_size) +static func upgrade_cached_baked_mesh_materials( + mesh: Mesh, + uv_scale: float = 1.0, + mip_bias: float = -1.25, + sharpen_strength: float = 0.35) -> Mesh: + + var array_mesh := mesh as ArrayMesh + if array_mesh == null: + return mesh + + for surface_idx in array_mesh.get_surface_count(): + var mat := array_mesh.surface_get_material(surface_idx) + if not (mat is ShaderMaterial): + continue + var shader_mat := mat as ShaderMaterial + var tex: Texture2D = shader_mat.get_shader_parameter("tex0") + if tex == null: + continue + var upgraded := ShaderMaterial.new() + upgraded.shader = _get_single_texture_shader() + upgraded.set_shader_parameter("uv_scale", uv_scale) + upgraded.set_shader_parameter("mip_bias", mip_bias) + upgraded.set_shader_parameter("sharpen_strength", sharpen_strength) + upgraded.set_shader_parameter("tex0", tex) + array_mesh.surface_set_material(surface_idx, upgraded) + + return array_mesh + + static func get_tile_origin(data: Dictionary) -> Vector3: var found := false var min_x := 0.0 @@ -469,6 +528,105 @@ static func _build_baked_tile_payload( return payload +static func _build_control_splat_tile_payload( + data: Dictionary, + image_cache: Dictionary, + extracted_dir: String, + lod: int, + texture_size: int) -> Dictionary: + + var payload := _build_baked_tile_geometry_payload(data, lod) + if payload.is_empty(): + return {} + + var tex_names: PackedStringArray = data.get("textures", PackedStringArray()) + var texture_images := _build_terrain_texture_images(tex_names, image_cache, extracted_dir, texture_size) + var texture_array := _build_terrain_texture_array_from_images(texture_images) + if texture_array == null: + return payload + + payload = _build_control_splat_tile_geometry_payload(data, lod, tex_names.size()) + if payload.is_empty(): + return {} + + var alpha_atlas := _build_tile_alpha_atlas(data) + var layer_index_map := _build_tile_layer_index_map(data, tex_names.size()) + var material := _build_control_splat_material(texture_array, alpha_atlas, layer_index_map) + var mesh: Mesh = payload.get("mesh", null) + if mesh != null: + mesh.surface_set_material(0, material) + + payload["texture_array"] = texture_array + payload["texture_images"] = texture_images + payload["alpha_atlas"] = alpha_atlas + payload["layer_index_map"] = layer_index_map + payload["texture_names"] = tex_names + return payload + + +static func _build_control_splat_tile_geometry_payload(data: Dictionary, lod: int, texture_count: int) -> Dictionary: + var tile_origin := get_tile_origin(data) + var verts := PackedVector3Array() + var nrms := PackedVector3Array() + var uvs_arr := PackedVector2Array() + var colors := PackedColorArray() + var indices := PackedInt32Array() + var max_id := mini(maxi(texture_count - 1, 0), 255) + + for chunk in data.get("chunks", []) as Array: + if chunk.is_empty(): + continue + + var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array()) + var positions := _build_chunk_positions(heights) + if positions.is_empty(): + continue + + var geometry := _build_chunk_geometry( + positions, + chunk.get("normals", PackedVector3Array()), + chunk.get("holes", 0), + lod) + + var local_verts: PackedVector3Array = geometry["verts"] + if local_verts.is_empty(): + continue + + var layer_color := _get_chunk_layer_color(chunk, max_id) + var offset: Vector3 = chunk.get("origin", Vector3.ZERO) - tile_origin + var base := verts.size() + + for v: Vector3 in local_verts: + var tv := v + offset + verts.append(tv) + uvs_arr.append(Vector2(tv.x / TILE_SIZE, tv.z / TILE_SIZE)) + colors.append(layer_color) + for n: Vector3 in geometry["nrms"]: + nrms.append(n) + for idx: int in geometry["indices"]: + indices.append(base + idx) + + if verts.is_empty(): + return {} + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + arrays[Mesh.ARRAY_NORMAL] = nrms + arrays[Mesh.ARRAY_TEX_UV] = uvs_arr + arrays[Mesh.ARRAY_COLOR] = colors + arrays[Mesh.ARRAY_INDEX] = indices + + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + + return { + "mesh": mesh, + "position": Vector3.ZERO, + "name": "ControlSplatTileLOD%d" % lod, + } + + static func _build_baked_tile_payload_pair( data: Dictionary, image_cache: Dictionary, @@ -874,6 +1032,8 @@ static func _build_baked_texture_material(tex: Texture2D) -> Material: var mat := ShaderMaterial.new() mat.shader = _get_single_texture_shader() mat.set_shader_parameter("uv_scale", 1.0) + mat.set_shader_parameter("mip_bias", -1.25) + mat.set_shader_parameter("sharpen_strength", 0.35) mat.set_shader_parameter("tex0", tex) if tex: _baked_texture_material_cache[tex.get_instance_id()] = mat @@ -887,6 +1047,8 @@ static func _build_single_texture_material(tex: Texture2D) -> Material: var mat := ShaderMaterial.new() mat.shader = _get_single_texture_shader() mat.set_shader_parameter("uv_scale", 8.0) + mat.set_shader_parameter("mip_bias", -0.75) + mat.set_shader_parameter("sharpen_strength", 0.0) mat.set_shader_parameter("tex0", tex) if tex: _single_texture_material_cache[tex.get_instance_id()] = mat @@ -897,6 +1059,7 @@ static func _build_layered_material(textures: Array, alpha_maps: Array) -> Shade var mat := ShaderMaterial.new() mat.shader = _get_terrain_shader() mat.set_shader_parameter("uv_scale", 8.0) + mat.set_shader_parameter("mip_bias", -0.75) mat.set_shader_parameter("layer_count", textures.size()) var base_tex: Texture2D = textures[0] @@ -939,6 +1102,234 @@ static func _get_empty_alpha_texture() -> Texture2D: return _empty_alpha_texture +static func _build_terrain_texture_images( + tex_names: PackedStringArray, + image_cache: Dictionary, + extracted_dir: String, + texture_size: int) -> Array[Image]: + + if tex_names.is_empty(): + var fallback := Image.create(maxi(16, texture_size), maxi(16, texture_size), false, Image.FORMAT_RGBA8) + fallback.fill(Color(0.4, 0.55, 0.3, 1.0)) + fallback.generate_mipmaps() + return [fallback] + + var target_size := maxi(16, texture_size) + var images: Array[Image] = [] + for tex_name in tex_names: + var source_img := _load_image(tex_name, image_cache, extracted_dir) + var img: Image = null + if source_img != null: + img = source_img.duplicate() + else: + img = Image.create(target_size, target_size, false, Image.FORMAT_RGBA8) + img.fill(Color(0.4, 0.55, 0.3, 1.0)) + + if img.get_format() != Image.FORMAT_RGBA8: + img.convert(Image.FORMAT_RGBA8) + if img.get_width() != target_size or img.get_height() != target_size: + img.resize(target_size, target_size, Image.INTERPOLATE_LANCZOS) + img.generate_mipmaps() + images.append(img) + + return images + + +static func _build_terrain_texture_array_from_images(images: Array) -> Texture2DArray: + if images.is_empty(): + return null + + var fallback: Image = null + for image_variant in images: + var candidate := image_variant as Image + if candidate != null and candidate.get_width() > 0 and candidate.get_height() > 0: + fallback = candidate + break + if fallback == null: + fallback = Image.create(16, 16, false, Image.FORMAT_RGBA8) + fallback.fill(Color(0.4, 0.55, 0.3, 1.0)) + fallback.generate_mipmaps() + + var valid_images: Array[Image] = [] + for image_variant in images: + var img := image_variant as Image + if img != null and img.get_width() > 0 and img.get_height() > 0: + valid_images.append(img) + continue + valid_images.append(fallback) + + if valid_images.is_empty(): + return null + + var texture_array := Texture2DArray.new() + var err := texture_array.create_from_images(valid_images) + if err != OK: + return null + return texture_array + + +static func _build_tile_alpha_atlas(data: Dictionary) -> Texture2D: + var atlas_size := 16 * 64 + var image := Image.create(atlas_size, atlas_size, false, Image.FORMAT_RGBA8) + image.fill(Color(0.0, 0.0, 0.0, 1.0)) + var tile_origin := get_tile_origin(data) + + for chunk in data.get("chunks", []) as Array: + if chunk.is_empty(): + continue + var chunk_coord := _get_chunk_grid_coord(chunk, tile_origin) + var base_x := chunk_coord.x * 64 + var base_y := chunk_coord.y * 64 + var alpha_maps: Array = chunk.get("alpha_maps", []) + for py in range(64): + for px in range(64): + var a0 := 0 + var a1 := 0 + var a2 := 0 + if alpha_maps.size() > 0: + var bytes0: PackedByteArray = alpha_maps[0] + if bytes0.size() == 64 * 64: + a0 = bytes0[py * 64 + px] + if alpha_maps.size() > 1: + var bytes1: PackedByteArray = alpha_maps[1] + if bytes1.size() == 64 * 64: + a1 = bytes1[py * 64 + px] + if alpha_maps.size() > 2: + var bytes2: PackedByteArray = alpha_maps[2] + if bytes2.size() == 64 * 64: + a2 = bytes2[py * 64 + px] + image.set_pixel( + base_x + px, + base_y + py, + Color8(a0, a1, a2, 255)) + + return ImageTexture.create_from_image(image) + + +static func _build_tile_layer_index_map(data: Dictionary, texture_count: int) -> Texture2D: + var image := Image.create(16, 16, false, Image.FORMAT_RGBA8) + image.fill(Color8(0, 0, 0, 255)) + var tile_origin := get_tile_origin(data) + var max_id := mini(maxi(texture_count - 1, 0), 255) + + for chunk in data.get("chunks", []) as Array: + if chunk.is_empty(): + continue + var layers: Array = chunk.get("layers", []) + if layers.is_empty(): + continue + var ids := PackedByteArray([0, 0, 0, 0]) + for i in range(mini(4, layers.size())): + var layer: Dictionary = layers[i] + ids[i] = clampi(int(layer.get("texture_id", 0)), 0, max_id) + + var chunk_coord := _get_chunk_grid_coord(chunk, tile_origin) + image.set_pixel( + chunk_coord.x, + chunk_coord.y, + Color8(ids[0], ids[1], ids[2], ids[3])) + + return ImageTexture.create_from_image(image) + + +static func _get_chunk_layer_color(chunk: Dictionary, max_id: int) -> Color: + var layers: Array = chunk.get("layers", []) + if layers.is_empty(): + return Color(0.0, 0.0, 0.0, 0.0) + + var ids := PackedFloat32Array([0.0, 0.0, 0.0, 0.0]) + for i in range(mini(4, layers.size())): + var layer: Dictionary = layers[i] + ids[i] = float(clampi(int(layer.get("texture_id", 0)), 0, max_id)) / 255.0 + return Color(ids[0], ids[1], ids[2], ids[3]) + + +static func _get_chunk_grid_coord(chunk: Dictionary, tile_origin: Vector3) -> Vector2i: + var origin: Vector3 = chunk.get("origin", Vector3.ZERO) + var local := origin - tile_origin + return Vector2i( + clampi(int(round(local.x / CHUNK_SIZE)), 0, 15), + clampi(int(round(local.z / CHUNK_SIZE)), 0, 15)) + + +static func _build_control_splat_material( + texture_array: Texture2DArray, + alpha_atlas: Texture2D, + layer_index_map: Texture2D) -> ShaderMaterial: + + var mat := ShaderMaterial.new() + mat.shader = _get_control_splat_shader() + mat.set_shader_parameter("terrain_tex", texture_array) + mat.set_shader_parameter("alpha_atlas", alpha_atlas) + mat.set_shader_parameter("layer_index_map", layer_index_map) + mat.set_shader_parameter("uv_scale", 8.0) + mat.set_shader_parameter("mip_bias", -0.75) + return mat + + +static func _get_control_splat_shader() -> Shader: + if _control_splat_shader: + return _control_splat_shader + + _control_splat_shader = Shader.new() + _control_splat_shader.code = """ +shader_type spatial; +render_mode unshaded, cull_disabled, depth_draw_opaque; + +uniform sampler2DArray terrain_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; +uniform sampler2D alpha_atlas : filter_linear, repeat_disable; +uniform sampler2D layer_index_map : filter_nearest, repeat_disable; + +uniform float uv_scale = 8.0; +uniform float mip_bias = -0.75; +uniform vec3 light_dir = vec3(-0.35, 0.82, -0.45); +uniform float ambient = 0.62; +uniform float diffuse = 0.38; +uniform float hemi = 0.12; + +vec3 sample_layer(float layer, vec2 local_uv) { + return texture(terrain_tex, vec3(local_uv * uv_scale, layer), mip_bias).rgb; +} + +void fragment() { + vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999)); + vec2 chunk_f = floor(tile_uv * 16.0); + vec2 local_uv = fract(tile_uv * 16.0); + + vec4 packed_ids = COLOR; + vec4 layers = floor(packed_ids * 255.0 + vec4(0.5)); + vec3 alpha = texture(alpha_atlas, (chunk_f * 64.0 + local_uv * 64.0 + vec2(0.5)) / 1024.0).rgb; + + float w1 = alpha.r; + float w2 = alpha.g; + float w3 = alpha.b; + float w0 = max(0.0, 1.0 - w1 - w2 - w3); + float sum = max(w0 + w1 + w2 + w3, 0.0001); + w0 /= sum; + w1 /= sum; + w2 /= sum; + w3 /= sum; + + vec3 albedo = + sample_layer(layers.x, local_uv) * w0 + + sample_layer(layers.y, local_uv) * w1 + + sample_layer(layers.z, local_uv) * w2 + + sample_layer(layers.w, local_uv) * w3; + + vec3 n = normalize(NORMAL); + if (!FRONT_FACING) { + n = -n; + } + vec3 l = normalize(light_dir); + float ndl = max(dot(n, l), 0.0); + float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + float light = ambient + diffuse * ndl + hemi * hemi_term; + ALBEDO = albedo * light; +} +""" + return _control_splat_shader + + static func _build_tile_baked_albedo( data: Dictionary, image_cache: Dictionary, @@ -1106,6 +1497,7 @@ render_mode unshaded, cull_disabled, depth_draw_opaque; uniform int layer_count = 1; uniform float uv_scale = 8.0; +uniform float mip_bias = -0.75; uniform vec3 light_dir = vec3(-0.35, 0.82, -0.45); uniform float ambient = 0.62; uniform float diffuse = 0.38; @@ -1114,10 +1506,10 @@ uniform float hemi = 0.12; void fragment() { vec2 tiled_uv = UV * uv_scale; - vec4 c0 = texture(tex0, tiled_uv); - vec4 c1 = texture(tex1, tiled_uv); - vec4 c2 = texture(tex2, tiled_uv); - vec4 c3 = texture(tex3, tiled_uv); + vec4 c0 = texture(tex0, tiled_uv, mip_bias); + vec4 c1 = texture(tex1, tiled_uv, mip_bias); + vec4 c2 = texture(tex2, tiled_uv, mip_bias); + vec4 c3 = texture(tex3, tiled_uv, mip_bias); float w1 = layer_count > 1 ? texture(alpha1, UV).r : 0.0; float w2 = layer_count > 2 ? texture(alpha2, UV).r : 0.0; @@ -1165,11 +1557,29 @@ render_mode unshaded, cull_disabled, depth_draw_opaque; uniform sampler2D tex0 : source_color, filter_linear_mipmap_anisotropic, repeat_enable; uniform float uv_scale = 8.0; +uniform float mip_bias = -0.75; +uniform float sharpen_strength = 0.0; uniform vec3 light_dir = vec3(-0.35, 0.82, -0.45); uniform float ambient = 0.62; uniform float diffuse = 0.38; uniform float hemi = 0.12; +vec3 sample_sharp(sampler2D tex, vec2 uv) { + vec3 center = texture(tex, uv, mip_bias).rgb; + if (sharpen_strength <= 0.001) { + return center; + } + vec2 texel = 1.0 / vec2(textureSize(tex, 0)); + vec2 min_uv = vec2(0.001); + vec2 max_uv = vec2(0.999); + vec3 side = + texture(tex, clamp(uv + vec2(texel.x, 0.0), min_uv, max_uv), mip_bias).rgb + + texture(tex, clamp(uv - vec2(texel.x, 0.0), min_uv, max_uv), mip_bias).rgb + + texture(tex, clamp(uv + vec2(0.0, texel.y), min_uv, max_uv), mip_bias).rgb + + texture(tex, clamp(uv - vec2(0.0, texel.y), min_uv, max_uv), mip_bias).rgb; + return clamp(center * (1.0 + 4.0 * sharpen_strength) - side * sharpen_strength, vec3(0.0), vec3(1.0)); +} + void fragment() { vec3 n = normalize(NORMAL); if (!FRONT_FACING) { @@ -1179,7 +1589,7 @@ void fragment() { float ndl = max(dot(n, l), 0.0); float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); float light = ambient + diffuse * ndl + hemi * hemi_term; - ALBEDO = texture(tex0, UV * uv_scale).rgb * light; + ALBEDO = sample_sharp(tex0, UV * uv_scale) * light; } """ return _single_texture_shader diff --git a/data/cache/bake_logs/wmo_render_cache_20260625_170522.err.log b/data/cache/bake_logs/wmo_render_cache_20260625_170522.err.log deleted file mode 100644 index e69de29..0000000 diff --git a/data/cache/bake_logs/wmo_render_cache_20260625_170522.out.log b/data/cache/bake_logs/wmo_render_cache_20260625_170522.out.log deleted file mode 100644 index 4f5b2af..0000000 --- a/data/cache/bake_logs/wmo_render_cache_20260625_170522.out.log +++ /dev/null @@ -1,410 +0,0 @@ -Godot Engine v4.6.1.stable.official.14d19694e - https://godotengine.org - -Scanning ADT tiles... -Found 368 unique WMO models from 687 tiles. -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/transport_shipwreck.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/wreckedelvendestroyer/elvendestroyerwreckback.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/wreckedelvendestroyer/elvendestroyerwreckfront.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guardtower/guardtower.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_humantwostory/duskwoodabandoned_humantwostory.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/chapel/duskwoodchapel.wmo -WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_spidermine/md_spidermine_nokobolds.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cryptonerm/md_cryptonerm.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_human_farm/duskwoodabandoned_human_farm.wmo -WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_spidermine/md_spidermine.wmo -[10/368] baked=10 skipped=0 failed=0 -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/shipwreck_a.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_human_farm/duskwood_human_farm.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_stable/duskwood_stable.wmo -WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_inn/duskwood_inn.wmo -WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/ld_shadowfang/ld_shadowfang.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_04.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_gate.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lighthouse.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/shipwreck_b.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_human_farm_burnt/westfallfarmhouseburnt.wmo -[20/368] baked=20 skipped=0 failed=0 -WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mountaincave_medium.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_barn_silo/silo.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_barn/westfall_barn.wmo -WMOLoader: loaded 38 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_deadmines/az_deadmines_a.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/wetlands/wetlandshumandock01/wetlandshumandock01.wmo -WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_deadmines/az_deadmines_c.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_barn/duskwoodabandoned_barn.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/westfall/westfallgrainsilo01/westfallgrainsilo01.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_humantwostory/duskwood_humantwostory.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_blacksmith/duskwoodabandoned_blacksmith.wmo -[30/368] baked=30 skipped=0 failed=0 -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_barn/duskwood_barn.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/duskwood/chasmbridge/duskwoodchasmbridge.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_1room.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_blacksmith/duskwood_blacksmith.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_townhall_nowall/duskwood_townhall_nowall.wmo -WMOLoader: loaded 27 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/kz_gnomeragon/kz_gnomeragon.wmo -WMOLoader: loaded 286 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/stormwind.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiece01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpost01.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/transports/transport_ship/transportship.wmo -[40/368] baked=40 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowmain.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowstair.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowtwr.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_shipshelter.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_docks.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowstr.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/northrend/buildings/human/icebreaker/transport_icebreaker_ship_nomasts.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/stormwindhangar.wmo -WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_human_farm/westfall_human_farm.wmo -[50/368] baked=50 skipped=0 failed=0 -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_humantwostory/westfall_humantwostory.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_human_farm/moonbrook_human_farm.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_stable/westfall_stable.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_lumbermill/westfall_lumbermill.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonlonghouse.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_humantwostory/moonbrook_humantwostory.wmo -WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_inn/moonbrook_inn.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_blacksmith/moonbrook_blacksmith.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_townhall_nowall/moonbrook_townhall_nowall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_tower_01.wmo -[60/368] baked=60 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/swampofsorrows/swampdock01/swampdock01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollshop/trollshop01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/swampofsorrows/swampbridge01/swampbridge01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollshoppingmall/trollshoppingmall01.wmo -WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_crypt/md_crypt.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/duskwood/coveredbridge/duskwoodcoveredbridge.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guardtower_ruined/ruinedhumanguardtower01.wmo -WMOLoader: loaded 206 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/undercity/undercity.wmo -WMOLoader: loaded 26 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_barracks/duskwood_barracks.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/fenriskeep/fenriskeep.wmo -[70/368] baked=70 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_human_farm_burnt/duskwoodfarmhouseburnt.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_lumbermill/duskwoodabandoned_lumbermill.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_townhall/duskwood_townhall.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/gnomehut/gnomehut.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/stonetalon/landingpad/landingpad01.wmo -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/tanaris/trolltent/trolltentsand01.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_farm/farm.wmo -WMOLoader: loaded 28 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_barracks/human_barracks.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/widebridge/elwynnwidebridge.wmo -[80/368] baked=80 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/lionbridge/elwynnlionbridge.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_magetower/westfall_magetower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins16/stranglethornruins16.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins12/stranglethornruins12.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins11/stranglethornruins11.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins04/stranglethornruins04.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/giantseaturtle02/giantseaturtle02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins12.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/snakespine/snakespinegiantarch.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins05.wmo -[90/368] baked=90 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins16.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_fortress2.wmo -WMOLoader: loaded 46 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/mausoleum/mausoleumactive.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/undercity/undercitywall01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/lorderonzeppelintower/lorderonzeppelin.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/lorderonzeppelintower/lorderonzeppelin_nd_transport.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpieceruin02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpostruined01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallposttunnel01.wmo -[100/368] baked=100 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsmed01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingssmall01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsmed02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiecestairs01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingssmall02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsbig03.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsbig02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/northrend/dalaran/dalaran_sewer_oldworld_architecture.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/humantwostory/humantwostory.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonguardtower.wmo -[110/368] baked=110 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/alterac/greenhouse01.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/townhall/townhall.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonhq.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/goldshireblacksmith/goldshireblacksmith.wmo -WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_variantb.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_animalden/animalden.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/gnomehut/gnomehuttwostory.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_iceden.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhut_desert.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/tanaris/trolltent/trolltentdesert01.wmo -[120/368] baked=120 skipped=0 failed=0 -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_anvilmarpass/anvilmarpass.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/anvilmar/anvilmar.wmo -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/abbeygate/abbeygate01.wmo -WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/goldshireinn/goldshireinn.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stable/stable.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_barn_silo/barn.wmo -WMOLoader: loaded 22 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_varianta.wmo -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_crypt/md_crypt_c.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/duskwood/opengrave/duskwoodopengrave.wmo -[130/368] baked=130 skipped=0 failed=0 -WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_inn/duskwoodabandoned_inn.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/darkshoredocks/darkshoredocks01.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orczeppelinhouse/orczeppelinhouse.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/orctower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcblacksmith/orcblacksmith.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornarena/stranglegladiatorarena.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_trollden.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornarenagate/stranglearenagate01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/ropebridge/ropebridgesmall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_railing.wmo -[140/368] baked=140 skipped=0 failed=0 -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_house1.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_house2.wmo -WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_pirateship/pirateship_plank.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_humantwostory/redridge_humantwostory.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks02/redridgedocks02.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_townhall_nowall/duskwoodabandoned_townhall_nowall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpieceruin01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/gateruined01.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_human_farm/redridge_human_farm.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_snogremound1room.wmo -[150/368] baked=150 skipped=0 failed=0 -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_mushroomden.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/intactprisontower/intactprisontower.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cavetunnels/pvp_alterac_ent01.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mountaincave_medium_e.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_townhall_nowall/westfall_townhall_nowall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidabledoodads/menethildocks/menethildocks.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/ironforge/footbridge/ironfootbridge.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/ironforge/ironforgeropebridge01/ironforgeropebridge01.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_blacksmith/snow_blacksmith/snow_blacksmith.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_1story_hovel/snow_hovel/hovel_machine.wmo -[160/368] baked=160 skipped=0 failed=0 -WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave03.wmo -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_inn/snow_inn/snow_inn.wmo -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_stable/redridge_stable.wmo -WMOLoader: loaded 14 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/nsabbey/nsabbey.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/abbeygate02/abbeygate02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/worldtree/duskworldtree.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfmoonwell/dsnightelfmoonwellornate.wmo -WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_ogremound.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/ropebridge/ropebridge.wmo -[170/368] baked=170 skipped=0 failed=0 -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/oilplatform/oilplatformsmall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhut01.wmo -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave03.wmo -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/scarlet_monestary/scarlet_monestary.wmo -WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_snogremound.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidabledoodads/wetlandsfootbridge/wetlandsfootbridge01.wmo -WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_inn/westfall_inn.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/wetlands/bridgebigwhite/bridgebigwhite.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiece03.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiece02.wmo -[180/368] baked=180 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/gate03.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpost02.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/transports/passengership/transportship_a.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_stable/dwarven_stable.wmo -WMOLoader: loaded 104 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/cities/ironforge/ironforge.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_1story_hovel/snow_hovel/snow_hovel.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_2story_hovel/snow_2story/snow_2story.wmo -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_variante.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/searinggorge/darkirontower.wmo -WMOLoader: loaded 45 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_blackrock/blackrock.wmo -[190/368] baked=190 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/altarofstorms/altarofstorms.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/magetower/magetower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/mulgore/taurentent01/taurentent01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_lumbermill/duskwood_lumbermill.wmo -WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave05.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/holdingpen/holdingpenbamboo.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/swamporctower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/zulgurub/zulgurubentrance.wmo -WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_grogremound.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_townhall_nowall/redridge_townhall_nowall.wmo -[200/368] baked=200 skipped=0 failed=0 -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_magetower/redridge_magetower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_lumbermill/redridge_lumbermill.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_guardtower/westfall_guardtower.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_blacksmith/redridge_blacksmith.wmo -WMOLoader: loaded 26 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_barracks/redridge_barracks.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/gate01.wmo -WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_inn/andorhalabandoned_inn.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsbig01.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cavetunnels/md_cavetunnel01.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guildhouses/guildhouseb.wmo -[210/368] baked=210 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/hillsbradterracewall/hillsbradterracewall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/humanbuildings/outlandruinedmagetower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/humanbuildings/outlandruinhumanbarracks01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/durnholdedestroyedwalls/durnholdedestroyedwall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_01.wmo -WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_barracks/wetlands_barracks/wet_dwarfbarracks.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_tower_02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/stromguard/stromguard.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_magetower/duskwood_magetower.wmo -[220/368] baked=220 skipped=0 failed=0 -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_1story_hovel/mud_hovel/mud_hovel.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_2story_hovel/mud_2story/mud_2story.wmo -WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/smalltunnel.wmo -WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/mediumtunnel.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_snowstable/dwarven_snowstable.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/golbolarplatform/golbolarplatform.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave04.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/searinggorge/darkironcrane.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/searinggorge/searinggorgescaffold/searinggorgescaffold.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_blackrock/blackrock2.wmo -[230/368] baked=230 skipped=0 failed=0 -WMOLoader: loaded 21 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcfortress/blackrockorcfortress.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollburrow/trollburrow.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/lumbermill/lumbermill.wmo -WMOLoader: loaded 19 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_crypt/md_crypt_d.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhutsmall01.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/zulgurub/zulgurubcity.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/ziggurat/trollziggurat.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins04.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins08.wmo -[240/368] baked=240 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins09.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins11.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/ropebridge/ropebridgeshort.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins15.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_ogremound1room.wmo -WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cryptschool/md_cryptschool.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/uthershrine/uthershrine.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_griffonaviary/dwarven_griffonaviary.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_blacksmith/wetland_blacksmith/wetland_blacksmith.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_03.wmo -[250/368] baked=250 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/wreckedbuildinghcorner02/wreckedbuildinghcorner02.wmo -WMOLoader: loaded 16 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ruinedkeep/ruinedkeep.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/wreckedbuildinghcorner01/wreckedbuildinghcorner01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks01/redridgedocks01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks03/redridgedocks03.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_snowtower/dwarven_snowtower.wmo -WMOLoader: loaded 22 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/largetunnel.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcmedium/orcmedium.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcmagetower/orcmagetower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orckennel/orckennel.wmo -[260/368] baked=260 skipped=0 failed=0 -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave.wmo -WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_karazahn/kharazan.wmo -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingssm01_winecellar.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuilding_collapsed.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/strangleruinstair/strangleruinstair.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins17.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhotel/troll_hotel01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins14.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins01/stranglethornruins01.wmo -WMOLoader: loaded 25 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_stratholme_plaguewood_tunnel/md_stratholme_plaguewood_tunnel.wmo -[270/368] baked=270 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/plaguelandbridge/plaguelandsbridge.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks/redridgesmalldock01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/lowwallpost01.wmo -WMOLoader: loaded 15 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ruinedkeep/ruinedkeep_crypt.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ruinedkeep/ruinedkeep_crypt_facade.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_guardtower/dwarven_guardtower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/bridge/arathistonebridge02.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_tavern/wetlands_tavern/wet_tavern.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/dustwallowmarsh/crashedzippelin/final/crashedzippelin.wmo -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/thandolspan_bridge/thandolspan.wmo -[280/368] baked=280 skipped=0 failed=0 -WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave04.wmo -WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/smalltunnel_snow.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvensentinels/dwarvensentinel01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvensentinels/dwarvensentinel02.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_warmden.wmo -WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_brogremound.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/blackrockorctower.wmo -WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_inn/redridge_inn.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedamagedbridge/redridgedamagedbridge.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks/redridgesmalldock02.wmo -[290/368] baked=290 skipped=0 failed=0 -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_grogremound1room.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcbarracks/abandonedorcbarracks.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/azrclassicalelfruin10.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/azrclassicalelfruin01.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/undeadziggurat/undeadziggurat.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cavetunnels/md_warmcavetunnel01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfhunterslodge/nightelfhunterslodge.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfsmallhouse/nightelfsmallhouse.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_ogremound_medium.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mountaincave_medium_d.wmo -[300/368] baked=300 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/wetlands/wetlandswagon/final/wetlandsbustedwagon.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvendam/dwarvendam01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvendock/dwarvendock01.wmo -WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_inn/wetlands_inn/wet_inn.wmo -WMOLoader: loaded 23 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/kz_uldaman/kz_uldaman_a.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgefootbridge/redridgefootbridge.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/draenibuildings/outland_draeni_hut_1.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/draenibuildings/draenihuthooded01.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollburrow/swamptrollburrow.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/swampofsorrows/bridgestranglethorn/bridgestranglethorn.wmo -[310/368] baked=310 skipped=0 failed=0 -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_variantd.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/abandonedorctower.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorctower04.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_brogremound1room.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/darkportal/darkportal.wmo -WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/undeadslaughterhouse/slaughterhouse.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/ld_stratholme/stratholme_a.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/naxportal/epl_portal.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/worldtree/worldtree.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trolltent.wmo -[320/368] baked=320 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel7.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave08.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/wetlands_grimbatol/wetlands_grimbatol.wmo -WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cryptsimpleent/md_cryptsimpleent.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/kz_uldaman/kz_uldaman_c.wmo -WMOLoader: loaded 36 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/sunkentemple/az_sunkentemple.wmo -WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcmedium/swamporcmedium.wmo -WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcgreathall/swamporcgreathall.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcblacksmith/swamporcblacksmith.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave07.wmo -[330/368] baked=330 skipped=0 failed=0 -WMOLoader: loaded 26 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_barracks/westfall_barracks.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_blacksmith/westfall_blacksmith.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonoubliette.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_stable/westfall_stableb.wmo -WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_hordemine/md_hordemine.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorctower02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorctower01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorcpigfarm01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/pvp/collidabledoodads/netents/nightelftent02_pvp.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfhunterslodge/dsnightelfhunterslodge.wmo -[340/368] baked=340 skipped=0 failed=0 -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/ld_stratholme/stratholme_c.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin10.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin03.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin05.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel1.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel2.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel3.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel4.wmo -[350/368] baked=350 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel5.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel6.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcityarena.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcityampatheater.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/excavationsite/excavationplatform02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/excavationsite/excavationplatform01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/excavationsite/excavationplatform03.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/giantseaturtle01/giantseaturtle01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhut_burn01.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/ratchet_dock.wmo -[360/368] baked=360 skipped=0 failed=0 -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/shipwreck_c.wmo -WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/nd_necropolis/deathknightnecropolis.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_human_farm/duskwood_human_farm_closed.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/nd_necropolis/nd_necropolisteleport02.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_barn/duskwood_barn_closed.wmo -WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/cathedral/cathedral.wmo -WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/chapel/redridgechapel.wmo -WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guardtower/guardtower_damaged.wmo -[368/368] baked=368 skipped=0 failed=0 -WMO render cache finished. baked=368 skipped=0 failed=0 elapsed=37.26s diff --git a/project.godot b/project.godot index b3f0bd5..6eb634e 100644 --- a/project.godot +++ b/project.godot @@ -26,4 +26,4 @@ enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg") [rendering] rendering_device/driver.windows="d3d12" -rendering/rendering_device/d3d12/max_resource_descriptors=524288 +rendering/rendering_device/d3d12/max_resource_descriptors=1048576 diff --git a/src/resources/.gitignore b/src/resources/.gitignore index cd7ea44..419f545 100644 --- a/src/resources/.gitignore +++ b/src/resources/.gitignore @@ -1,3 +1,5 @@ * !.gitignore !wmo_streaming_resource.gd +!splat_adt_tile.gd +!control_splat_adt_tile.gd diff --git a/src/resources/control_splat_adt_tile.gd b/src/resources/control_splat_adt_tile.gd new file mode 100644 index 0000000..10c7daa --- /dev/null +++ b/src/resources/control_splat_adt_tile.gd @@ -0,0 +1,17 @@ +extends Resource +class_name ControlSplatADTTile + +const FORMAT_VERSION := 3 + +@export var format_version: int = FORMAT_VERSION +@export var map_name: String = "" +@export var tile_x: int = 0 +@export var tile_y: int = 0 +@export var tile_origin: Vector3 = Vector3.ZERO +@export var lod: int = 0 +@export var texture_size: int = 256 +@export var terrain_mesh: Mesh +@export var texture_images: Array[Image] = [] +@export var alpha_atlas: Texture2D +@export var layer_index_map: Texture2D +@export var texture_names: PackedStringArray = PackedStringArray() diff --git a/src/resources/splat_adt_tile.gd b/src/resources/splat_adt_tile.gd new file mode 100644 index 0000000..a552069 --- /dev/null +++ b/src/resources/splat_adt_tile.gd @@ -0,0 +1,12 @@ +extends Resource +class_name SplatADTTile + +const FORMAT_VERSION := 1 + +@export var format_version: int = FORMAT_VERSION +@export var map_name: String = "" +@export var tile_x: int = 0 +@export var tile_y: int = 0 +@export var tile_origin: Vector3 = Vector3.ZERO +@export var lod: int = 3 +@export var terrain_mesh: Mesh diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd new file mode 100644 index 0000000..8d3e1ce --- /dev/null +++ b/src/scenes/player/third_person_wow_controller.gd @@ -0,0 +1,218 @@ +extends CharacterBody3D + +const TILE_SIZE := 533.33333 +const CHUNK_SIZE := TILE_SIZE / 16.0 +const UNIT_SIZE := CHUNK_SIZE / 8.0 + +@export var extracted_dir: String = "res://data/extracted" +@export var map_name: String = "Azeroth" +@export var spawn_tile_x: int = 42 +@export var spawn_tile_y: int = 28 +@export var spawn_at_tile_center: bool = true + +@export var run_speed: float = 7.0 +@export var backward_speed: float = 4.5 +@export var strafe_speed: float = 4.5 +@export var sprint_multiplier: float = 6.0 +@export var acceleration: float = 28.0 +@export var mouse_sensitivity: float = 0.003 +@export var camera_pitch_min: float = deg_to_rad(-65.0) +@export var camera_pitch_max: float = deg_to_rad(35.0) +@export var camera_height: float = 1.7 +@export var camera_distance: float = 8.0 +@export var ground_offset: float = 0.05 +@export var ground_snap_speed: float = 24.0 + +@export var camera_pivot_path: NodePath = NodePath("CameraPivot") +@export var camera_path: NodePath = NodePath("CameraPivot/Camera3D") +@export var visual_path: NodePath = NodePath("Visual") + +var _camera_pivot: Node3D +var _camera: Camera3D +var _visual: Node3D +var _captured := false +var _yaw := 0.0 +var _pitch := deg_to_rad(-18.0) +var _horizontal_velocity := Vector3.ZERO +var _adt_cache: Dictionary = {} + + +func _ready() -> void: + _camera_pivot = get_node_or_null(camera_pivot_path) as Node3D + _camera = get_node_or_null(camera_path) as Camera3D + _visual = get_node_or_null(visual_path) as Node3D + if _camera: + _camera.current = true + _camera.far = 50000.0 + if spawn_at_tile_center: + global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE + global_position.z = (float(spawn_tile_y) + 0.5) * TILE_SIZE + var ground := _sample_ground_height(global_position) + if is_finite(ground): + global_position.y = ground + ground_offset + _yaw = rotation.y + _apply_camera_transform() + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: + _captured = event.pressed + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE + elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: + _captured = false + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + + if _captured and event is InputEventMouseMotion: + _yaw -= event.relative.x * mouse_sensitivity + _pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, camera_pitch_min, camera_pitch_max) + rotation.y = _yaw + _apply_camera_transform() + + +func _physics_process(delta: float) -> void: + var input_dir := _get_input_dir() + var target_velocity := Vector3.ZERO + if input_dir.length_squared() > 0.0: + target_velocity = _movement_vector(input_dir) + + _horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta) + global_position += _horizontal_velocity * delta + + var ground := _sample_ground_height(global_position) + if is_finite(ground): + var target_y := ground + ground_offset + global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0)) + + if _visual and _horizontal_velocity.length_squared() > 0.01: + _visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z) + + _apply_camera_transform() + + +func _get_input_dir() -> Vector2: + var dir := Vector2.ZERO + if Input.is_key_pressed(KEY_W): + dir.y -= 1.0 + if Input.is_key_pressed(KEY_S): + dir.y += 1.0 + if Input.is_key_pressed(KEY_A): + dir.x -= 1.0 + if Input.is_key_pressed(KEY_D): + dir.x += 1.0 + return dir.normalized() + + +func _movement_vector(input_dir: Vector2) -> Vector3: + var forward := -global_basis.z + var right := global_basis.x + forward.y = 0.0 + right.y = 0.0 + forward = forward.normalized() + right = right.normalized() + + var speed_z := run_speed if input_dir.y < 0.0 else backward_speed + var speed_x := strafe_speed + var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0 + return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint + + +func _apply_camera_transform() -> void: + if _camera_pivot: + _camera_pivot.position = Vector3(0.0, camera_height, 0.0) + _camera_pivot.rotation.x = _pitch + if _camera: + _camera.position = Vector3(0.0, 0.0, camera_distance) + _camera.rotation = Vector3.ZERO + + +func _sample_ground_height(world_pos: Vector3) -> float: + var tx := int(floor(world_pos.x / TILE_SIZE)) + var ty := int(floor(world_pos.z / TILE_SIZE)) + var data := _load_adt(tx, ty) + if data.is_empty(): + return NAN + + var tile_origin := _get_tile_origin(data) + var local := world_pos - tile_origin + var chunk_x := clampi(int(floor(local.x / CHUNK_SIZE)), 0, 15) + var chunk_y := clampi(int(floor(local.z / CHUNK_SIZE)), 0, 15) + var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin) + if chunk.is_empty(): + return NAN + + var origin: Vector3 = chunk.get("origin", Vector3.ZERO) + var chunk_local := world_pos - origin + var gx := clampf(chunk_local.x / UNIT_SIZE, 0.0, 8.0) + var gz := clampf(chunk_local.z / UNIT_SIZE, 0.0, 8.0) + var x0 := clampi(int(floor(gx)), 0, 8) + var z0 := clampi(int(floor(gz)), 0, 8) + var x1 := mini(x0 + 1, 8) + var z1 := mini(z0 + 1, 8) + var fx := gx - float(x0) + var fz := gz - float(z0) + var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array()) + if heights.size() < 145: + return NAN + + var h00 := heights[_outer(z0, x0)] + var h10 := heights[_outer(z0, x1)] + var h01 := heights[_outer(z1, x0)] + var h11 := heights[_outer(z1, x1)] + var hx0 := lerpf(h00, h10, fx) + var hx1 := lerpf(h01, h11, fx) + return origin.y + lerpf(hx0, hx1, fz) + + +func _load_adt(tx: int, ty: int) -> Dictionary: + var key := "%d_%d" % [tx, ty] + if _adt_cache.has(key): + return _adt_cache[key] + if not ClassDB.class_exists("ADTLoader"): + _adt_cache[key] = {} + return {} + + var res_path := extracted_dir.path_join("World/Maps/%s/%s_%d_%d.adt" % [map_name, map_name, tx, ty]) + var abs_path := ProjectSettings.globalize_path(res_path) + if not FileAccess.file_exists(abs_path): + _adt_cache[key] = {} + return {} + + var loader = ClassDB.instantiate("ADTLoader") + var data: Dictionary = loader.call("load_adt", abs_path) if loader else {} + _adt_cache[key] = data + return data + + +func _find_chunk(data: Dictionary, chunk_x: int, chunk_y: int, tile_origin: Vector3) -> Dictionary: + for chunk in data.get("chunks", []) as Array: + if chunk.is_empty(): + continue + var origin: Vector3 = chunk.get("origin", Vector3.ZERO) + var local := origin - tile_origin + var cx := clampi(int(round(local.x / CHUNK_SIZE)), 0, 15) + var cy := clampi(int(round(local.z / CHUNK_SIZE)), 0, 15) + if cx == chunk_x and cy == chunk_y: + return chunk + return {} + + +func _get_tile_origin(data: Dictionary) -> Vector3: + var found := false + var min_x := 0.0 + var min_z := 0.0 + for chunk in data.get("chunks", []) as Array: + if chunk.is_empty(): + continue + var origin: Vector3 = chunk.get("origin", Vector3.ZERO) + if not found: + min_x = origin.x + min_z = origin.z + found = true + else: + min_x = minf(min_x, origin.x) + min_z = minf(min_z, origin.z) + return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO + + +func _outer(row: int, col: int) -> int: + return row * 17 + col diff --git a/src/scenes/player/third_person_wow_controller.gd.uid b/src/scenes/player/third_person_wow_controller.gd.uid new file mode 100644 index 0000000..42550cc --- /dev/null +++ b/src/scenes/player/third_person_wow_controller.gd.uid @@ -0,0 +1 @@ +uid://ltn2ko5kxvo4 diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index b3bf747..5c28b59 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://f1nqi4emji47"] [ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"] -[ext_resource type="Script" uid="uid://bauggobg40psr" path="res://src/scenes/camera/fly_camera.gd" id="2_flycam"] +[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"] [sub_resource type="Sky" id="Sky_1"] @@ -12,41 +12,82 @@ ambient_light_source = 3 ambient_light_color = Color(1, 1, 1, 1) ambient_light_energy = 0.5 +[sub_resource type="StandardMaterial3D" id="PlayerMaterial_1"] +albedo_color = Color(0.25, 0.42, 0.85, 1) +roughness = 0.65 + +[sub_resource type="CapsuleMesh" id="PlayerCapsuleMesh_1"] +material = SubResource("PlayerMaterial_1") +radius = 0.45 +height = 2.1 + +[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] +radius = 0.45 +height = 2.1 + [node name="StreamingWorld" type="Node3D" unique_id=1063159974] script = ExtResource("1_stream") -camera_path = NodePath("Camera3D") +camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D") +quality_preset = "High" update_interval = 0.1 tiles_per_tick = 1 max_concurrent_tile_tasks = 1 -chunk_ops_per_tick = 16 tile_lod_remove_ops_per_tick = 1 -tile_finalize_ops_per_tick = 1 -detail_asset_ops_per_tick = 1 m2_build_groups_per_tick = 1 -m2_multimesh_batch_size = 32 -m2_mesh_finalize_ops_per_tick = 1 -wmo_build_instances_per_tick = 1 -wmo_render_group_ops_per_tick = 24 -wmo_max_runtime_scene_mb = 8.0 -prewarm_tile_margin = 2 -retain_tile_margin = 3 -boundary_prefetch_threshold = 0.4 -streaming_tile_cache_dir = "res://data/cache/baked_terrain_stream_v1" +m2_multimesh_batch_size = 64 +wmo_render_group_ops_per_tick = 16 cached_tile_mesh_limit = 48 -wmo_render_cache_dir = "res://data/cache/wmo_render_v1" +terrain_quality_mesh_cache_limit = 128 +lod0_radius_chunks = 10 +lod1_radius_chunks = 20 +lod2_radius_chunks = 40 +lod2_tile_radius = 5 +prewarm_tile_margin = 3 +retain_tile_margin = 4 +full_lod_prewarm_tiles = 1.5 +boundary_prefetch_threshold = 0.42 +auto_position_camera = false enable_occlusion_culling = false +terrain_full_quality_radius_tiles = 5 +terrain_control_splat_primary = true +terrain_control_splat_hide_fallback = true +terrain_control_splat_hide_fallback_radius_tiles = 0 +terrain_control_splat_radius_tiles = 5 +max_active_terrain_control_splat_tiles = 121 +terrain_control_splat_cache_finalize_ops_per_tick = 8 +terrain_splat_quality_enabled = false enable_water = true -enable_wmo = true +m2_tile_radius = 3 +wmo_tile_radius = 5 +m2_visibility_range = 1600.0 +wmo_visibility_range = 3600.0 +terrain_quality_log_enabled = true +terrain_quality_log_interval = 1.0 hitch_profiler_enabled = true -hitch_profiler_threshold_ms = 20.0 -[node name="Camera3D" type="Camera3D" parent="." unique_id=502573687] -transform = Transform3D(1, 0, 0, 0, 0.707107, 0.707107, 0, -0.707107, 0.707107, 0, 300, 300) +[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 22666, 80, 15200) +script = ExtResource("2_player") +spawn_tile_x = 42 +spawn_tile_y = 28 +sprint_multiplier = 6.0 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer"] +position = Vector3(0, 1.05, 0) +shape = SubResource("PlayerCapsuleShape_1") + +[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer"] +position = Vector3(0, 1.05, 0) +mesh = SubResource("PlayerCapsuleMesh_1") + +[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer"] +position = Vector3(0, 1.7, 0) + +[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot"] +position = Vector3(0, 0, 8) current = true far = 50000.0 -script = ExtResource("2_flycam") -speed = 1200.0 -fast_mult = 8.0 +fov = 70.0 [node name="Sun" type="DirectionalLight3D" parent="." unique_id=1436804627] transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 0, 0) diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index ced32a9..4d69406 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -5,18 +5,27 @@ extends Node3D const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd") const BAKED_TILE_SCRIPT := preload("res://src/resources/baked_adt_tile.gd") const STREAMING_TILE_SCRIPT := preload("res://src/resources/streaming_adt_tile.gd") +const SPLAT_TILE_SCRIPT := preload("res://src/resources/splat_adt_tile.gd") +const CONTROL_SPLAT_TILE_SCRIPT := preload("res://src/resources/control_splat_adt_tile.gd") const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd") const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd") const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const REQUIRED_BAKED_TILE_FORMAT_VERSION := 4 +const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 +const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 const TILE_SIZE := 533.33333 const CHUNK_SIZE := TILE_SIZE / 16.0 const M2_RIGHT_YAW_OFFSET := 0.0 +const QUALITY_CUSTOM := "Custom" +const QUALITY_PERFORMANCE := "Performance" +const QUALITY_BALANCED := "Balanced" +const QUALITY_HIGH := "High" @export var extracted_dir: String = "res://data/extracted" @export var map_name: String = "Azeroth" @export var camera_path: NodePath +@export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM ## Seconds between streaming updates (lower = more responsive, higher = cheaper) @export var update_interval: float = 0.25 @@ -32,6 +41,10 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var tile_lod_remove_ops_per_tick: int = 2 ## Loaded tile resources finalized on the main thread per frame. @export var tile_finalize_ops_per_tick: int = 1 +## Full-quality baked terrain tile upgrades finalized per frame. +@export var terrain_upgrade_finalize_ops_per_tick: int = 1 +## Full-quality terrain tile resources loaded in parallel. +@export var max_concurrent_terrain_upgrade_tasks: int = 1 ## M2/WMO detail batches attached to loaded tiles per frame. @export var detail_asset_ops_per_tick: int = 1 ## M2 MultiMesh groups materialized per frame after background grouping. @@ -50,6 +63,8 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var streaming_update_distance: float = CHUNK_SIZE * 2.0 ## Recently built tile meshes kept for quick revisits. @export var cached_tile_mesh_limit: int = 24 +## Full-quality terrain meshes kept after an ADT is unloaded, so revisits do not flash back to low quality. +@export var terrain_quality_mesh_cache_limit: int = 24 @export var lod0_radius_chunks: int = 6 @export var lod1_radius_chunks: int = 12 @@ -77,6 +92,25 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var use_baked_tile_cache: bool = true @export var baked_tile_cache_dir: String = "res://data/cache/baked_terrain_v2" @export var streaming_tile_cache_dir: String = "res://data/cache/baked_terrain_stream_v1" +@export var terrain_control_splat_cache_dir: String = "res://data/cache/terrain_control_splat_v3" +@export var terrain_splat_cache_dir: String = "res://data/cache/terrain_splat_v1" +@export var terrain_full_quality_enabled: bool = true +@export var terrain_full_quality_radius_tiles: int = 1 +@export var terrain_control_splat_quality_enabled: bool = true +@export var terrain_control_splat_primary: bool = true +@export var terrain_control_splat_hide_fallback: bool = false +@export var terrain_control_splat_hide_fallback_radius_tiles: int = 0 +@export var terrain_control_splat_radius_tiles: int = 0 +@export var max_active_terrain_control_splat_tiles: int = 5 +@export var terrain_control_splat_cache_finalize_ops_per_tick: int = 1 +@export var terrain_splat_quality_enabled: bool = true +@export var terrain_splat_radius_tiles: int = 0 +@export var terrain_splat_lod: int = 0 +@export var terrain_splat_runtime_build_enabled: bool = false +@export var max_active_terrain_splat_tiles: int = 5 +@export var terrain_splat_cache_finalize_ops_per_tick: int = 1 +@export var max_concurrent_terrain_splat_tasks: int = 1 +@export var terrain_splat_builds_per_tick: int = 1 @export var use_chunk_terrain_lods: bool = false @export var enable_water: bool = false @export var enable_wmo: bool = true @@ -101,6 +135,8 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var debug_streaming: bool = false @export var runtime_stats_enabled: bool = false @export var runtime_stats_interval: float = 2.0 +@export var terrain_quality_log_enabled: bool = true +@export var terrain_quality_log_interval: float = 1.0 @export var hitch_profiler_enabled: bool = false @export var hitch_profiler_threshold_ms: float = 20.0 @@ -128,6 +164,12 @@ var _m2_mesh_finalize_queue: Array = [] var _wmo_build_jobs: Dictionary = {} var _wmo_build_queue: Array = [] var _tile_loading_tasks: Dictionary = {} +var _terrain_upgrade_tasks: Dictionary = {} +var _terrain_control_splat_cache_tasks: Dictionary = {} +var _terrain_splat_cache_tasks: Dictionary = {} +var _terrain_splat_tasks: Dictionary = {} +var _terrain_splat_result_mutex := Mutex.new() +var _terrain_splat_result_queue: Array = [] var _tile_result_mutex := Mutex.new() var _tile_result_queue: Array = [] var _shared_tex_cache: Dictionary = {} @@ -142,11 +184,14 @@ var _dbg_chunks_created := 0 var _dbg_chunks_null := 0 var _dbg_last_report := 0.0 var _runtime_stats_accum := 0.0 +var _terrain_quality_log_accum := 0.0 var _shutting_down := false var _last_refresh_focus_pos := Vector3.ZERO var _has_refresh_focus := false var _tile_mesh_cache: Dictionary = {} var _tile_mesh_cache_order: Array = [] +var _terrain_quality_mesh_cache: Dictionary = {} +var _terrain_quality_mesh_cache_order: Array = [] var _wmo_prototype_cache: Dictionary = {} var _wmo_render_cache: Dictionary = {} var _wmo_render_load_requests: Dictionary = {} @@ -162,6 +207,8 @@ var _wmo_registry: Dictionary = {} func _ready() -> void: + _apply_quality_preset() + if not ClassDB.class_exists("ADTLoader"): push_error("ADTLoader not found. Rebuild GDExtension first.") return @@ -192,6 +239,14 @@ func _ready() -> void: var baked_dir := ProjectSettings.globalize_path(baked_tile_cache_dir.path_join(map_name)) if not DirAccess.dir_exists_absolute(baked_dir): push_warning("Baked terrain cache not found: %s. Runtime will fall back to raw ADT builds." % baked_dir) + if terrain_control_splat_quality_enabled: + var control_splat_dir := ProjectSettings.globalize_path(terrain_control_splat_cache_dir.path_join(map_name)) + if not DirAccess.dir_exists_absolute(control_splat_dir): + push_warning("Control splat terrain cache not found: %s. High terrain quality will use baked atlas fallback." % control_splat_dir) + if terrain_splat_quality_enabled: + var splat_dir := ProjectSettings.globalize_path(terrain_splat_cache_dir.path_join(map_name)) + if not DirAccess.dir_exists_absolute(splat_dir): + push_warning("Terrain splat cache not found: %s. High terrain quality will use baked atlas fallback." % splat_dir) if enable_wmo: _place_global_wmo() @@ -217,6 +272,18 @@ func _process(delta: float) -> void: _drain_tile_load_results() _profile_section(timings, "finalize", section_start, profile_enabled) section_start = Time.get_ticks_usec() if profile_enabled else 0 + _drain_terrain_upgrade_results() + _profile_section(timings, "terrainup", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _drain_terrain_control_splat_cache_results() + _profile_section(timings, "terrainctl", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _drain_terrain_splat_cache_results() + _profile_section(timings, "terrainsplatcache", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _drain_terrain_splat_results() + _profile_section(timings, "terrainsplat", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 _process_queues() _profile_section(timings, "terrainq", section_start, profile_enabled) section_start = Time.get_ticks_usec() if profile_enabled else 0 @@ -239,6 +306,7 @@ func _process(delta: float) -> void: _profile_section(timings, "details", section_start, profile_enabled) section_start = Time.get_ticks_usec() if profile_enabled else 0 _tick_runtime_stats(delta) + _tick_terrain_quality_log(delta) _update_accum += delta var did_refresh := false @@ -262,6 +330,160 @@ func _exit_tree() -> void: _clear_streamed_world() +func _apply_quality_preset() -> void: + match quality_preset: + QUALITY_PERFORMANCE: + update_interval = 0.12 + tiles_per_tick = 1 + max_concurrent_tile_tasks = 1 + chunk_ops_per_tick = 16 + tile_lod_ops_per_tick = 1 + tile_lod_remove_ops_per_tick = 1 + tile_finalize_ops_per_tick = 1 + terrain_upgrade_finalize_ops_per_tick = 1 + max_concurrent_terrain_upgrade_tasks = 1 + detail_asset_ops_per_tick = 1 + m2_build_groups_per_tick = 1 + m2_multimesh_batch_size = 32 + m2_mesh_finalize_ops_per_tick = 1 + wmo_build_instances_per_tick = 1 + wmo_render_group_ops_per_tick = 16 + terrain_quality_mesh_cache_limit = 0 + lod0_radius_chunks = 6 + lod1_radius_chunks = 12 + lod2_radius_chunks = 24 + lod2_tile_radius = 3 + warm_tile_margin = 1 + prewarm_tile_margin = 2 + retain_tile_margin = 3 + full_lod_prewarm_tiles = 1.0 + boundary_prefetch_threshold = 0.4 + terrain_full_quality_enabled = false + terrain_full_quality_radius_tiles = 0 + terrain_control_splat_quality_enabled = false + terrain_control_splat_primary = false + terrain_control_splat_hide_fallback = false + terrain_control_splat_hide_fallback_radius_tiles = 0 + terrain_control_splat_radius_tiles = 0 + max_active_terrain_control_splat_tiles = 0 + terrain_control_splat_cache_finalize_ops_per_tick = 1 + terrain_splat_quality_enabled = false + terrain_splat_radius_tiles = 0 + terrain_splat_lod = 3 + terrain_splat_runtime_build_enabled = false + max_active_terrain_splat_tiles = 0 + terrain_splat_cache_finalize_ops_per_tick = 1 + max_concurrent_terrain_splat_tasks = 1 + terrain_splat_builds_per_tick = 1 + m2_tile_radius = 1 + wmo_tile_radius = 3 + m2_visibility_range = 700.0 + wmo_visibility_range = 1800.0 + m2_cast_shadows = false + wmo_cast_shadows = false + terrain_cast_shadows = false + QUALITY_BALANCED: + update_interval = 0.10 + tiles_per_tick = 1 + max_concurrent_tile_tasks = 1 + chunk_ops_per_tick = 20 + tile_lod_ops_per_tick = 1 + tile_lod_remove_ops_per_tick = 1 + tile_finalize_ops_per_tick = 1 + terrain_upgrade_finalize_ops_per_tick = 1 + max_concurrent_terrain_upgrade_tasks = 1 + detail_asset_ops_per_tick = 1 + m2_build_groups_per_tick = 1 + m2_multimesh_batch_size = 32 + m2_mesh_finalize_ops_per_tick = 1 + wmo_build_instances_per_tick = 1 + wmo_render_group_ops_per_tick = 20 + terrain_quality_mesh_cache_limit = 24 + lod0_radius_chunks = 8 + lod1_radius_chunks = 16 + lod2_radius_chunks = 32 + lod2_tile_radius = 4 + warm_tile_margin = 1 + prewarm_tile_margin = 2 + retain_tile_margin = 3 + full_lod_prewarm_tiles = 1.0 + boundary_prefetch_threshold = 0.4 + terrain_full_quality_enabled = true + terrain_full_quality_radius_tiles = 1 + terrain_control_splat_quality_enabled = true + terrain_control_splat_primary = true + terrain_control_splat_hide_fallback = false + terrain_control_splat_hide_fallback_radius_tiles = 0 + terrain_control_splat_radius_tiles = 0 + max_active_terrain_control_splat_tiles = 1 + terrain_control_splat_cache_finalize_ops_per_tick = 1 + terrain_splat_quality_enabled = false + terrain_splat_radius_tiles = 0 + terrain_splat_lod = 0 + terrain_splat_runtime_build_enabled = false + max_active_terrain_splat_tiles = 1 + terrain_splat_cache_finalize_ops_per_tick = 1 + max_concurrent_terrain_splat_tasks = 1 + terrain_splat_builds_per_tick = 1 + m2_tile_radius = 2 + wmo_tile_radius = 4 + m2_visibility_range = 1100.0 + wmo_visibility_range = 2600.0 + m2_cast_shadows = false + wmo_cast_shadows = false + terrain_cast_shadows = false + QUALITY_HIGH: + update_interval = 0.10 + tiles_per_tick = 1 + max_concurrent_tile_tasks = 1 + chunk_ops_per_tick = 24 + tile_lod_ops_per_tick = 1 + tile_lod_remove_ops_per_tick = 1 + tile_finalize_ops_per_tick = 1 + terrain_upgrade_finalize_ops_per_tick = 1 + max_concurrent_terrain_upgrade_tasks = 1 + detail_asset_ops_per_tick = 1 + m2_build_groups_per_tick = 1 + m2_multimesh_batch_size = 64 + m2_mesh_finalize_ops_per_tick = 1 + wmo_build_instances_per_tick = 1 + wmo_render_group_ops_per_tick = 16 + terrain_quality_mesh_cache_limit = 48 + lod0_radius_chunks = 10 + lod1_radius_chunks = 20 + lod2_radius_chunks = 40 + lod2_tile_radius = 5 + warm_tile_margin = 1 + prewarm_tile_margin = 3 + retain_tile_margin = 4 + full_lod_prewarm_tiles = 1.5 + boundary_prefetch_threshold = 0.42 + terrain_full_quality_enabled = true + terrain_full_quality_radius_tiles = 5 + terrain_control_splat_quality_enabled = true + terrain_control_splat_primary = true + terrain_control_splat_hide_fallback = true + terrain_control_splat_hide_fallback_radius_tiles = 0 + terrain_control_splat_radius_tiles = 5 + max_active_terrain_control_splat_tiles = 121 + terrain_control_splat_cache_finalize_ops_per_tick = 8 + terrain_splat_quality_enabled = false + terrain_splat_radius_tiles = 0 + terrain_splat_lod = 0 + terrain_splat_runtime_build_enabled = false + max_active_terrain_splat_tiles = 5 + terrain_splat_cache_finalize_ops_per_tick = 1 + max_concurrent_terrain_splat_tasks = 1 + terrain_splat_builds_per_tick = 1 + m2_tile_radius = 3 + wmo_tile_radius = 5 + m2_visibility_range = 1600.0 + wmo_visibility_range = 3600.0 + m2_cast_shadows = false + wmo_cast_shadows = false + terrain_cast_shadows = false + + func _tick_editor_streaming() -> void: if editor_reload_now: editor_reload_now = false @@ -494,9 +716,12 @@ func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionar state["desired_lods"] = {} state["desired_tile_lod"] = -1 state["desired_tile_lod_visible"] = false + state = _sync_terrain_quality(state, focus_pos) state = _sync_detail_assets(state, focus_pos) _tile_states[key] = state + _prune_active_terrain_control_splat_tiles(focus_pos) + _prune_active_terrain_splat_tiles(focus_pos) _rebuild_chunk_queues() @@ -636,11 +861,14 @@ func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bo var total_ms := float(Time.get_ticks_usec() - start_usec) / 1000.0 if total_ms < maxf(1.0, hitch_profiler_threshold_ms): return - print("HITCH %.2fms refresh=%s queues[tile=%d loading=%d detail=%d m2task=%d m2mesh=%d m2build=%d wmobuild=%d wmogroups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] %s" % [ + print("HITCH %.2fms refresh=%s queues[tile=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d detail=%d m2task=%d m2mesh=%d m2build=%d wmobuild=%d wmogroups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] %s" % [ total_ms, str(did_refresh), _tile_load_queue.size(), _tile_loading_tasks.size(), + _terrain_upgrade_tasks.size(), + _terrain_control_splat_cache_tasks.size(), + _terrain_splat_queue_size(), _detail_asset_queue.size(), _m2_group_tasks.size(), _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), @@ -675,6 +903,9 @@ func _tick_runtime_stats(delta: float) -> void: var m2_tiles := 0 var m2_multimeshes := 0 var m2_instances := 0 + var terrain_baked_full := 0 + var terrain_control_splat := 0 + var terrain_splat := 0 for state_variant in _tile_states.values(): if not (state_variant is Dictionary): @@ -708,6 +939,13 @@ func _tick_runtime_stats(delta: float) -> void: var prepared_tile_lod_rid: RID = state.get("prepared_tile_lod_rid", RID()) if prepared_tile_lod_rid.is_valid(): tile_lod_prepared += 1 + var terrain_source := String(state.get("quality_terrain_source", "")) + if terrain_source == "baked_full": + terrain_baked_full += 1 + elif terrain_source == "control_splat_cache": + terrain_control_splat += 1 + elif terrain_source == "splat_cache" or terrain_source == "splat": + terrain_splat += 1 var root: Node = state.get("root", null) if root == null or not is_instance_valid(root): @@ -721,12 +959,18 @@ func _tick_runtime_stats(delta: float) -> void: m2_multimeshes += int(m2_counts["multimeshes"]) m2_instances += int(m2_counts["instances"]) - print("PERF fps=%.1f tiles=%d wanted=%d retained=%d loading=%d cache=%d lod_rid=%d lod_node=%d lod_hidden=%d lod_prepared=%d chunk_rid=%d chunk_node=%d queues[tile=%d detail=%d m2_task=%d m2_mesh=%d m2_build=%d wmo_build=%d wmo_groups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] m2_tiles=%d m2_mm=%d m2_inst=%d wmo=%d" % [ + print("PERF fps=%.1f tiles=%d wanted=%d retained=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d src[baked=%d ctl=%d splat=%d] cache=%d lod_rid=%d lod_node=%d lod_hidden=%d lod_prepared=%d chunk_rid=%d chunk_node=%d queues[tile=%d detail=%d m2_task=%d m2_mesh=%d m2_build=%d wmo_build=%d wmo_groups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] m2_tiles=%d m2_mm=%d m2_inst=%d wmo=%d" % [ Engine.get_frames_per_second(), _tile_states.size(), wanted_tiles, retained_tiles, _tile_loading_tasks.size(), + _terrain_upgrade_tasks.size(), + _terrain_control_splat_cache_tasks.size(), + _terrain_splat_queue_size(), + terrain_baked_full, + terrain_control_splat, + terrain_splat, _tile_mesh_cache.size(), tile_lod_rids, tile_lod_nodes, @@ -752,6 +996,85 @@ func _tick_runtime_stats(delta: float) -> void: ]) +func _tick_terrain_quality_log(delta: float) -> void: + if not terrain_quality_log_enabled or Engine.is_editor_hint(): + return + _terrain_quality_log_accum += delta + if _terrain_quality_log_accum < maxf(terrain_quality_log_interval, 0.25): + return + _terrain_quality_log_accum = 0.0 + + var target := 0 + var ready := 0 + var pending := 0 + var fallback_baked := 0 + var fallback_stream := 0 + var hidden_wait := 0 + var missing_cache := 0 + var stale_or_other := 0 + var focus_state_source := "none" + var focus_tile := _world_to_tile(_last_focus_pos) + + for state_variant in _tile_states.values(): + if not (state_variant is Dictionary): + continue + var state: Dictionary = state_variant + if not bool(state.get("wanted", false)): + continue + if not terrain_control_splat_quality_enabled: + continue + if not _tile_within_control_splat_terrain_radius(state, _last_focus_pos): + continue + + target += 1 + var key := String(state.get("key", "")) + var source := String(state.get("quality_terrain_source", "")) + var is_focus := int(state.get("tx", -9999)) == focus_tile.x and int(state.get("ty", -9999)) == focus_tile.y + if is_focus: + focus_state_source = source if not source.is_empty() else "stream" + + if source == "control_splat_cache": + ready += 1 + continue + if not key.is_empty() and _terrain_control_splat_cache_tasks.has(key): + pending += 1 + continue + + var cache_path := _get_terrain_control_splat_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0))) + if not ResourceLoader.exists(cache_path): + missing_cache += 1 + continue + + var desired_lod := int(state.get("desired_tile_lod", -1)) + if desired_lod < 0: + hidden_wait += 1 + elif source == "baked_full": + fallback_baked += 1 + else: + var tile_lod := int(state.get("tile_lod", -1)) + if tile_lod >= 0: + fallback_stream += 1 + else: + stale_or_other += 1 + + var done := ready >= target and pending == 0 and missing_cache == 0 and hidden_wait == 0 and fallback_baked == 0 and fallback_stream == 0 + print("TERRAIN_QUALITY target=%d ready=%d pending=%d fallback[baked=%d stream=%d hidden=%d other=%d] missing_cache=%d queue=%d focus=%d_%d:%s done=%s" % [ + target, + ready, + pending, + fallback_baked, + fallback_stream, + hidden_wait, + stale_or_other, + missing_cache, + _terrain_control_splat_cache_tasks.size(), + focus_tile.x, + focus_tile.y, + focus_state_source, + str(done), + ]) + + func _accumulate_multimesh_stats(node: Node, counts: Dictionary) -> void: if node is MultiMeshInstance3D: var mmi := node as MultiMeshInstance3D @@ -903,6 +1226,659 @@ func _drain_tile_load_results() -> void: _finalize_loaded_baked_tile(request, loaded_tile) +func _sync_terrain_quality(state: Dictionary, focus_pos: Vector3) -> Dictionary: + state = _drop_splat_quality_if_inactive(state, focus_pos) + if not terrain_full_quality_enabled: + return state + if state.get("stream_tile", null) == null: + return state + if not bool(state.get("wanted", false)): + return state + if not _tile_within_full_terrain_radius(state, focus_pos): + return state + + var key := String(state.get("key", "")) + var quality_mesh: Mesh = state.get("quality_terrain_mesh", null) + var quality_source := String(state.get("quality_terrain_source", "")) + var control_splat_requested := false + if terrain_control_splat_quality_enabled and _tile_within_control_splat_terrain_radius(state, focus_pos): + if quality_source == "control_splat_cache": + return state + if key.is_empty(): + return state + if not _can_activate_terrain_control_splat(key, state, focus_pos): + if quality_mesh != null: + return state + return state + if terrain_control_splat_primary and _tile_is_focus_tile(state, focus_pos): + var immediate_state := _load_terrain_control_splat_immediate(state, focus_pos) + if String(immediate_state.get("quality_terrain_source", "")) == "control_splat_cache": + return immediate_state + control_splat_requested = _request_terrain_control_splat_cache(state) + if control_splat_requested: + return state + + if state.get("baked_tile", null) != null: + return state + + if terrain_splat_quality_enabled and _tile_within_splat_terrain_radius(state, focus_pos): + if quality_source == "splat_cache" or quality_source == "splat": + return state + if key.is_empty(): + return state + if not _can_activate_terrain_splat(key, state, focus_pos): + if quality_mesh != null: + return state + return state + if _request_terrain_splat_cache(state): + return state + if terrain_splat_runtime_build_enabled: + if _terrain_splat_tasks.has(key): + return state + if _terrain_splat_tasks.size() >= maxi(1, max_concurrent_terrain_splat_tasks): + return state + var task_id: int = WorkerThreadPool.add_task(_load_terrain_splat_task.bind(key, String(state.get("path", "")))) + _terrain_splat_tasks[key] = task_id + return state + + if quality_mesh != null: + return state + if terrain_control_splat_primary and control_splat_requested: + return state + if terrain_control_splat_primary and terrain_control_splat_quality_enabled and _tile_within_control_splat_terrain_radius(state, focus_pos): + var control_path := _get_terrain_control_splat_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0))) + if ResourceLoader.exists(control_path): + return state + + if key.is_empty() or _terrain_upgrade_tasks.has(key): + return state + if _terrain_upgrade_tasks.size() >= maxi(1, max_concurrent_terrain_upgrade_tasks): + return state + + var baked_path := _get_baked_tile_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0))) + if not ResourceLoader.exists(baked_path): + return state + + var err := ResourceLoader.load_threaded_request( + baked_path, + "", + false, + ResourceLoader.CACHE_MODE_IGNORE) + if err == OK or err == ERR_BUSY: + _terrain_upgrade_tasks[key] = { + "path": baked_path, + "tx": int(state.get("tx", 0)), + "ty": int(state.get("ty", 0)), + } + return state + + +func _drop_splat_quality_if_inactive(state: Dictionary, focus_pos: Vector3) -> Dictionary: + var quality_source := String(state.get("quality_terrain_source", "")) + if quality_source == "control_splat_cache": + if bool(state.get("wanted", false)) and _tile_within_control_splat_terrain_radius(state, focus_pos): + return state + return _drop_splat_quality(state) + if quality_source != "splat_cache" and quality_source != "splat": + return state + if bool(state.get("wanted", false)) and _tile_within_splat_terrain_radius(state, focus_pos): + return state + + return _drop_splat_quality(state) + + +func _drop_splat_quality(state: Dictionary) -> Dictionary: + state.erase("quality_terrain_mesh") + state.erase("quality_terrain_source") + var key := String(state.get("key", "")) + if not key.is_empty(): + state = _restore_quality_terrain_mesh(key, state) + _remove_tile_mesh_cache_entries(key) + return state + + +func _can_activate_terrain_splat(key: String, state: Dictionary, focus_pos: Vector3) -> bool: + var limit := maxi(0, max_active_terrain_splat_tiles) + if limit <= 0: + return false + if _tile_is_focus_tile(state, focus_pos): + return true + if _terrain_splat_cache_tasks.has(key) or _terrain_splat_tasks.has(key): + return true + return _active_terrain_splat_count(focus_pos) < limit + + +func _can_activate_terrain_control_splat(key: String, state: Dictionary, focus_pos: Vector3) -> bool: + var limit := maxi(0, max_active_terrain_control_splat_tiles) + if limit <= 0: + return false + if _tile_is_focus_tile(state, focus_pos): + return true + if _terrain_control_splat_cache_tasks.has(key): + return true + return _active_terrain_control_splat_count(focus_pos) < limit + + +func _active_terrain_control_splat_count(focus_pos: Vector3) -> int: + var count := _terrain_control_splat_cache_tasks.size() + for state_variant in _tile_states.values(): + if not (state_variant is Dictionary): + continue + var state: Dictionary = state_variant + if not bool(state.get("wanted", false)): + continue + if String(state.get("quality_terrain_source", "")) != "control_splat_cache": + continue + if _tile_within_control_splat_terrain_radius(state, focus_pos): + count += 1 + return count + + +func _active_terrain_splat_count(focus_pos: Vector3) -> int: + var count := _terrain_splat_cache_tasks.size() + _terrain_splat_tasks.size() + for state_variant in _tile_states.values(): + if not (state_variant is Dictionary): + continue + var state: Dictionary = state_variant + if not bool(state.get("wanted", false)): + continue + var source := String(state.get("quality_terrain_source", "")) + if source != "splat_cache" and source != "splat": + continue + if _tile_within_splat_terrain_radius(state, focus_pos): + count += 1 + return count + + +func _prune_active_terrain_control_splat_tiles(focus_pos: Vector3) -> void: + var limit := maxi(0, max_active_terrain_control_splat_tiles) + if limit <= 0: + for key_variant in _tile_states.keys(): + var key := String(key_variant) + var state: Dictionary = _tile_states[key] + if String(state.get("quality_terrain_source", "")) == "control_splat_cache": + state = _drop_splat_quality(state) + state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + _tile_states[key] = state + return + + var active: Array = [] + for key_variant in _tile_states.keys(): + var key := String(key_variant) + var state: Dictionary = _tile_states[key] + if String(state.get("quality_terrain_source", "")) != "control_splat_cache": + continue + if not bool(state.get("wanted", false)) or not _tile_within_control_splat_terrain_radius(state, focus_pos): + state = _drop_splat_quality(state) + state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + _tile_states[key] = state + continue + active.append({ + "key": key, + "dist_sq": _tile_dist_sq(state, focus_pos), + "focus": _tile_is_focus_tile(state, focus_pos), + }) + + if active.size() <= limit: + return + + active.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if bool(a["focus"]) != bool(b["focus"]): + return bool(a["focus"]) + return float(a["dist_sq"]) < float(b["dist_sq"])) + + var keep := {} + for i in range(mini(limit, active.size())): + keep[String(active[i]["key"])] = true + + for row in active: + var key := String(row["key"]) + if keep.has(key) or not _tile_states.has(key): + continue + var state: Dictionary = _tile_states[key] + state = _drop_splat_quality(state) + state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + _tile_states[key] = state + + +func _prune_active_terrain_splat_tiles(focus_pos: Vector3) -> void: + var limit := maxi(0, max_active_terrain_splat_tiles) + if limit <= 0: + for key_variant in _tile_states.keys(): + var key := String(key_variant) + var state: Dictionary = _tile_states[key] + var source := String(state.get("quality_terrain_source", "")) + if source == "splat_cache" or source == "splat": + state = _drop_splat_quality(state) + state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + _tile_states[key] = state + return + + var active: Array = [] + for key_variant in _tile_states.keys(): + var key := String(key_variant) + var state: Dictionary = _tile_states[key] + var source := String(state.get("quality_terrain_source", "")) + if source != "splat_cache" and source != "splat": + continue + if not bool(state.get("wanted", false)) or not _tile_within_splat_terrain_radius(state, focus_pos): + state = _drop_splat_quality(state) + state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + _tile_states[key] = state + continue + active.append({ + "key": key, + "dist_sq": _tile_dist_sq(state, focus_pos), + "focus": _tile_is_focus_tile(state, focus_pos), + }) + + if active.size() <= limit: + return + + active.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if bool(a["focus"]) != bool(b["focus"]): + return bool(a["focus"]) + return float(a["dist_sq"]) < float(b["dist_sq"])) + + var keep := {} + for i in range(mini(limit, active.size())): + keep[String(active[i]["key"])] = true + + for row in active: + var key := String(row["key"]) + if keep.has(key): + continue + if not _tile_states.has(key): + continue + var state: Dictionary = _tile_states[key] + state = _drop_splat_quality(state) + state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + _tile_states[key] = state + + +func _tile_is_focus_tile(state: Dictionary, focus_pos: Vector3) -> bool: + var focus_tile := _world_to_tile(focus_pos) + return int(state.get("tx", -9999)) == focus_tile.x and int(state.get("ty", -9999)) == focus_tile.y + + +func _request_terrain_control_splat_cache(state: Dictionary) -> bool: + var key := String(state.get("key", "")) + if key.is_empty(): + return false + if _terrain_control_splat_cache_tasks.has(key): + return true + + var cache_path := _get_terrain_control_splat_resource_path( + int(state.get("tx", 0)), + int(state.get("ty", 0))) + if not ResourceLoader.exists(cache_path): + return false + + var err := ResourceLoader.load_threaded_request( + cache_path, + "", + false, + ResourceLoader.CACHE_MODE_IGNORE) + if err == OK or err == ERR_BUSY: + _terrain_control_splat_cache_tasks[key] = { + "path": cache_path, + "tx": int(state.get("tx", 0)), + "ty": int(state.get("ty", 0)), + } + return true + return false + + +func _load_terrain_control_splat_immediate(state: Dictionary, focus_pos: Vector3) -> Dictionary: + var key := String(state.get("key", "")) + if key.is_empty(): + return state + var cache_path := _get_terrain_control_splat_resource_path( + int(state.get("tx", 0)), + int(state.get("ty", 0))) + if not ResourceLoader.exists(cache_path): + return state + + var control_tile: Resource = ResourceLoader.load(cache_path, "", ResourceLoader.CACHE_MODE_IGNORE) + if control_tile == null or control_tile.get_script() != CONTROL_SPLAT_TILE_SCRIPT: + push_warning("Invalid immediate control splat tile resource: %s" % cache_path) + return state + if int(control_tile.get("format_version")) < REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION: + push_warning("Outdated immediate control splat tile resource: %s" % cache_path) + return state + + var mesh: Mesh = control_tile.get("terrain_mesh") + if mesh == null: + push_warning("Immediate control splat tile has no terrain mesh: %s" % cache_path) + return state + mesh = mesh.duplicate(true) + if _builder.has_method("apply_control_splat_material"): + mesh = _builder.apply_control_splat_material( + mesh, + control_tile.get("texture_images"), + control_tile.get("alpha_atlas"), + control_tile.get("layer_index_map")) + if mesh == null or mesh.surface_get_material(0) == null: + push_warning("Immediate control splat material failed: %s" % cache_path) + return state + + state["quality_terrain_mesh"] = mesh + state["quality_terrain_source"] = "control_splat_cache" + state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + _terrain_control_splat_cache_tasks.erase(key) + var cache_key := _tile_mesh_cache_key(key, 0) + _tile_mesh_cache.erase(cache_key) + _tile_mesh_cache_order.erase(cache_key) + return state + + +func _request_terrain_splat_cache(state: Dictionary) -> bool: + var key := String(state.get("key", "")) + if key.is_empty(): + return false + if _terrain_splat_cache_tasks.has(key): + return true + + var cache_path := _get_terrain_splat_resource_path( + int(state.get("tx", 0)), + int(state.get("ty", 0))) + if not ResourceLoader.exists(cache_path): + return false + + var err := ResourceLoader.load_threaded_request( + cache_path, + "", + false, + ResourceLoader.CACHE_MODE_IGNORE) + if err == OK or err == ERR_BUSY: + _terrain_splat_cache_tasks[key] = { + "path": cache_path, + "tx": int(state.get("tx", 0)), + "ty": int(state.get("ty", 0)), + } + return true + return false + + +func _drain_terrain_upgrade_results() -> void: + var ready: Array[String] = [] + var budget := maxi(0, terrain_upgrade_finalize_ops_per_tick) + if budget <= 0: + return + + for key_variant in _terrain_upgrade_tasks.keys(): + if ready.size() >= budget: + break + var key := String(key_variant) + var pending: Dictionary = _terrain_upgrade_tasks[key] + var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) + if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: + ready.append(key) + + var changed := false + for key in ready: + if not _terrain_upgrade_tasks.has(key): + continue + var pending: Dictionary = _terrain_upgrade_tasks[key] + _terrain_upgrade_tasks.erase(key) + var path := String(pending.get("path", "")) + var status := ResourceLoader.load_threaded_get_status(path) + if status != ResourceLoader.THREAD_LOAD_LOADED: + continue + var baked_tile: Resource = ResourceLoader.load_threaded_get(path) + if baked_tile == null or baked_tile.get_script() != BAKED_TILE_SCRIPT: + continue + if not _tile_states.has(key): + continue + + var state: Dictionary = _tile_states[key] + if state.get("stream_tile", null) == null: + continue + if String(state.get("quality_terrain_source", "")) == "control_splat_cache": + continue + var baked_version: int = int(baked_tile.get("format_version")) + if baked_version < REQUIRED_BAKED_TILE_FORMAT_VERSION: + continue + state["baked_tile"] = baked_tile + var full_mesh: Mesh = baked_tile.get("full_mesh") + if full_mesh != null: + full_mesh = full_mesh.duplicate(true) + if _builder.has_method("upgrade_cached_baked_mesh_materials"): + full_mesh = _builder.upgrade_cached_baked_mesh_materials(full_mesh, 1.0, -1.25, 0.35) + state["quality_terrain_mesh"] = full_mesh + state["quality_terrain_source"] = "baked_full" + _store_quality_terrain_mesh(key, full_mesh, "baked_full") + state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos) + _tile_states[key] = state + _tile_mesh_cache.erase(_tile_mesh_cache_key(key, 0)) + _tile_mesh_cache_order.erase(_tile_mesh_cache_key(key, 0)) + changed = true + + if changed: + _rebuild_chunk_queues() + + +func _drain_terrain_control_splat_cache_results() -> void: + var ready: Array[String] = [] + var budget := maxi(0, terrain_control_splat_cache_finalize_ops_per_tick) + if budget <= 0: + return + + for key_variant in _terrain_control_splat_cache_tasks.keys(): + if ready.size() >= budget: + break + var key := String(key_variant) + var pending: Dictionary = _terrain_control_splat_cache_tasks[key] + var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) + if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: + ready.append(key) + + var changed := false + for key in ready: + if not _terrain_control_splat_cache_tasks.has(key): + continue + var pending: Dictionary = _terrain_control_splat_cache_tasks[key] + _terrain_control_splat_cache_tasks.erase(key) + var path := String(pending.get("path", "")) + var status := ResourceLoader.load_threaded_get_status(path) + if status != ResourceLoader.THREAD_LOAD_LOADED: + continue + var control_tile: Resource = ResourceLoader.load_threaded_get(path) + if control_tile == null or control_tile.get_script() != CONTROL_SPLAT_TILE_SCRIPT: + continue + if int(control_tile.get("format_version")) < REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION: + continue + if not _tile_states.has(key): + continue + + var state: Dictionary = _tile_states[key] + if not bool(state.get("wanted", false)): + continue + if not _tile_within_control_splat_terrain_radius(state, _last_focus_pos): + continue + if not _can_activate_terrain_control_splat(key, state, _last_focus_pos): + continue + + var mesh: Mesh = control_tile.get("terrain_mesh") + if mesh == null: + continue + mesh = mesh.duplicate(true) + if _builder.has_method("apply_control_splat_material"): + mesh = _builder.apply_control_splat_material( + mesh, + control_tile.get("texture_images"), + control_tile.get("alpha_atlas"), + control_tile.get("layer_index_map")) + + state["quality_terrain_mesh"] = mesh + state["quality_terrain_source"] = "control_splat_cache" + state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos) + _tile_states[key] = state + var cache_key := _tile_mesh_cache_key(key, 0) + _tile_mesh_cache.erase(cache_key) + _tile_mesh_cache_order.erase(cache_key) + changed = true + + if changed: + _rebuild_chunk_queues() + + +func _drain_terrain_splat_cache_results() -> void: + var ready: Array[String] = [] + var budget := maxi(0, terrain_splat_cache_finalize_ops_per_tick) + if budget <= 0: + return + + for key_variant in _terrain_splat_cache_tasks.keys(): + if ready.size() >= budget: + break + var key := String(key_variant) + var pending: Dictionary = _terrain_splat_cache_tasks[key] + var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", ""))) + if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED: + ready.append(key) + + var changed := false + for key in ready: + if not _terrain_splat_cache_tasks.has(key): + continue + var pending: Dictionary = _terrain_splat_cache_tasks[key] + _terrain_splat_cache_tasks.erase(key) + var path := String(pending.get("path", "")) + var status := ResourceLoader.load_threaded_get_status(path) + if status != ResourceLoader.THREAD_LOAD_LOADED: + continue + var splat_tile: Resource = ResourceLoader.load_threaded_get(path) + if splat_tile == null or splat_tile.get_script() != SPLAT_TILE_SCRIPT: + continue + if int(splat_tile.get("format_version")) < REQUIRED_SPLAT_TILE_FORMAT_VERSION: + continue + if not _tile_states.has(key): + continue + + var state: Dictionary = _tile_states[key] + if not bool(state.get("wanted", false)): + continue + if not _tile_within_splat_terrain_radius(state, _last_focus_pos): + continue + if not _can_activate_terrain_splat(key, state, _last_focus_pos): + continue + + var mesh: Mesh = splat_tile.get("terrain_mesh") + if mesh == null: + continue + + state["quality_terrain_mesh"] = mesh + state["quality_terrain_source"] = "splat_cache" + state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos) + _tile_states[key] = state + var cache_key := _tile_mesh_cache_key(key, 0) + _tile_mesh_cache.erase(cache_key) + _tile_mesh_cache_order.erase(cache_key) + changed = true + + if changed: + _rebuild_chunk_queues() + + +func _build_high_quality_stream_terrain_mesh(stream_mesh: Mesh, full_mesh: Mesh) -> Mesh: + if stream_mesh == null or full_mesh == null: + return null + var upgraded := stream_mesh.duplicate(true) as ArrayMesh + if upgraded == null: + return null + var surface_count := mini(upgraded.get_surface_count(), full_mesh.get_surface_count()) + for surface_idx in surface_count: + var material := full_mesh.surface_get_material(surface_idx) + if material != null: + upgraded.surface_set_material(surface_idx, material) + return upgraded + + +func _load_terrain_splat_task(key: String, adt_path: String) -> void: + var data: Dictionary = {} + if not adt_path.is_empty(): + var loader = ClassDB.instantiate("ADTLoader") + if loader: + data = loader.call("load_adt", adt_path) + + _terrain_splat_result_mutex.lock() + _terrain_splat_result_queue.append({ + "key": key, + "data": data, + }) + _terrain_splat_result_mutex.unlock() + + +func _drain_terrain_splat_results() -> void: + var results: Array = [] + var budget := maxi(0, terrain_splat_builds_per_tick) + if budget <= 0: + return + + _terrain_splat_result_mutex.lock() + while budget > 0 and not _terrain_splat_result_queue.is_empty(): + results.append(_terrain_splat_result_queue.pop_front()) + budget -= 1 + _terrain_splat_result_mutex.unlock() + + var changed := false + for result_variant in results: + if not (result_variant is Dictionary): + continue + var result: Dictionary = result_variant + var key := String(result.get("key", "")) + if _terrain_splat_tasks.has(key): + WorkerThreadPool.wait_for_task_completion(int(_terrain_splat_tasks[key])) + _terrain_splat_tasks.erase(key) + if not _tile_states.has(key): + continue + var state: Dictionary = _tile_states[key] + if not bool(state.get("wanted", false)): + continue + if not _tile_within_splat_terrain_radius(state, _last_focus_pos): + continue + if not _can_activate_terrain_splat(key, state, _last_focus_pos): + continue + + var data: Dictionary = result.get("data", {}) + if data.is_empty(): + continue + var payload: Dictionary = _builder.build_tile_coarse_render_payload( + data, + _shared_tex_cache, + ProjectSettings.globalize_path(extracted_dir), + maxi(0, terrain_splat_lod)) + var mesh: Mesh = payload.get("mesh", null) + if mesh == null: + continue + + state["quality_terrain_mesh"] = mesh + state["quality_terrain_source"] = "splat" + state["data"] = data + state["tex_names"] = data.get("textures", PackedStringArray()) + state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos) + _tile_states[key] = state + var cache_key := _tile_mesh_cache_key(key, 0) + _tile_mesh_cache.erase(cache_key) + _tile_mesh_cache_order.erase(cache_key) + changed = true + + if changed: + _rebuild_chunk_queues() + + func _wait_for_tile_tasks() -> void: for pending in _tile_loading_tasks.values(): if pending.get("mode", "raw") == "raw": @@ -957,6 +1933,43 @@ func _wait_for_tile_tasks() -> void: _wmo_render_build_jobs.clear() _wmo_render_build_queue.clear() + for pending in _terrain_upgrade_tasks.values(): + var path := String(pending.get("path", "")) + if path.is_empty(): + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status == ResourceLoader.THREAD_LOAD_LOADED: + ResourceLoader.load_threaded_get(path) + _terrain_upgrade_tasks.clear() + + for pending in _terrain_control_splat_cache_tasks.values(): + if not (pending is Dictionary): + continue + var path := String(pending.get("path", "")) + if path.is_empty(): + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status == ResourceLoader.THREAD_LOAD_LOADED: + ResourceLoader.load_threaded_get(path) + _terrain_control_splat_cache_tasks.clear() + + for task_id in _terrain_splat_tasks.values(): + WorkerThreadPool.wait_for_task_completion(int(task_id)) + _terrain_splat_tasks.clear() + for pending in _terrain_splat_cache_tasks.values(): + if not (pending is Dictionary): + continue + var path := String(pending.get("path", "")) + if path.is_empty(): + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status == ResourceLoader.THREAD_LOAD_LOADED: + ResourceLoader.load_threaded_get(path) + _terrain_splat_cache_tasks.clear() + _terrain_splat_result_mutex.lock() + _terrain_splat_result_queue.clear() + _terrain_splat_result_mutex.unlock() + func _should_accept_loaded_tile(request: Dictionary) -> bool: if _tile_states.has(request["key"]): @@ -1182,6 +2195,7 @@ func _finalize_loaded_streaming_tile(request: Dictionary, stream_tile: Resource) "wmo_refs": [], } + state = _restore_quality_terrain_mesh(key, state) state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos) state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos) _tile_states[key] = state @@ -1286,6 +2300,11 @@ func _create_tile_lod(op: Dictionary) -> void: func _get_tile_lod_mesh_for_state(key: String, state: Dictionary, lod: int) -> Mesh: + if lod <= 0: + var quality_mesh: Mesh = state.get("quality_terrain_mesh", null) + if quality_mesh != null: + return quality_mesh + var cache_key := _tile_mesh_cache_key(key, lod) var mesh: Mesh = _get_cached_tile_mesh(cache_key) if mesh != null: @@ -1457,6 +2476,10 @@ func _release_tile(key: String) -> void: var state: Dictionary = _tile_states[key] _unregister_tile_wmos(state) + _terrain_upgrade_tasks.erase(key) + _terrain_control_splat_cache_tasks.erase(key) + _terrain_splat_cache_tasks.erase(key) + _terrain_splat_tasks.erase(key) var tile_lod_rid: RID = state.get("tile_lod_rid", RID()) if tile_lod_rid.is_valid(): _free_render_instance(tile_lod_rid) @@ -1470,6 +2493,7 @@ func _release_tile(key: String) -> void: var tile_root: Node = state.get("root", null) if tile_root: tile_root.queue_free() + _remove_tile_mesh_cache_entries(key) _tile_states.erase(key) @@ -1479,6 +2503,13 @@ func _clear_streamed_world() -> void: _chunk_create_queue.clear() _tile_lod_remove_queue.clear() _tile_lod_create_queue.clear() + _terrain_upgrade_tasks.clear() + _terrain_control_splat_cache_tasks.clear() + _terrain_splat_cache_tasks.clear() + _terrain_splat_tasks.clear() + _terrain_splat_result_mutex.lock() + _terrain_splat_result_queue.clear() + _terrain_splat_result_mutex.unlock() _detail_asset_queue.clear() _detail_asset_queued.clear() for key in _m2_build_jobs.keys(): @@ -1515,6 +2546,7 @@ func _clear_streamed_world() -> void: _tile_states.clear() _clear_tile_mesh_cache() + _clear_quality_terrain_mesh_cache() _clear_wmo_registry() _ensure_world_wmo_root() if enable_wmo: @@ -1609,6 +2641,12 @@ func _compute_desired_tile_lod(state: Dictionary, cam_pos: Vector3) -> int: var tile_radius_sq: float = (lod2_tile_radius * TILE_SIZE) ** 2 var prewarm_radius_sq: float = (_runtime_load_tile_radius() * TILE_SIZE) ** 2 if state.get("stream_tile", null) != null: + if _state_has_applicable_quality_terrain(state, cam_pos): + return 0 + if _should_hide_terrain_fallback_for_quality(state, cam_pos): + return -1 + if state.get("baked_tile", null) != null and _tile_within_full_terrain_radius(state, cam_pos): + return 0 return 3 if tile_dist_sq <= prewarm_radius_sq else -1 if not use_chunk_terrain_lods: @@ -1634,6 +2672,41 @@ func _compute_desired_tile_lod_visible(state: Dictionary, cam_pos: Vector3) -> b return tile_dist_sq <= maxf(chunk_radius_sq, tile_radius_sq) +func _tile_within_full_terrain_radius(state: Dictionary, cam_pos: Vector3) -> bool: + var radius_tiles := maxi(0, terrain_full_quality_radius_tiles) + var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2 + return _tile_dist_sq(state, cam_pos) <= radius_sq + + +func _tile_within_control_splat_terrain_radius(state: Dictionary, cam_pos: Vector3) -> bool: + var radius_tiles := maxi(0, terrain_control_splat_radius_tiles) + var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2 + return _tile_dist_sq(state, cam_pos) <= radius_sq + + +func _tile_within_splat_terrain_radius(state: Dictionary, cam_pos: Vector3) -> bool: + var radius_tiles := maxi(0, terrain_splat_radius_tiles) + var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2 + return _tile_dist_sq(state, cam_pos) <= radius_sq + + +func _should_hide_terrain_fallback_for_quality(state: Dictionary, cam_pos: Vector3) -> bool: + if not terrain_control_splat_hide_fallback: + return false + if not terrain_control_splat_primary or not terrain_control_splat_quality_enabled: + return false + if not bool(state.get("wanted", false)): + return false + var radius_tiles := maxi(0, terrain_control_splat_hide_fallback_radius_tiles) + var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2 + if _tile_dist_sq(state, cam_pos) > radius_sq: + return false + if String(state.get("quality_terrain_source", "")) == "control_splat_cache": + return false + var control_path := _get_terrain_control_splat_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0))) + return ResourceLoader.exists(control_path) + + func _should_prepare_full_tile_lod(state: Dictionary, cam_pos: Vector3) -> bool: if use_chunk_terrain_lods or not prewarm_tile_lod_instances: return false @@ -1731,11 +2804,41 @@ func _get_streaming_tile_resource_path(tx: int, ty: int) -> String: return streaming_tile_cache_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty]) +func _get_terrain_control_splat_resource_path(tx: int, ty: int) -> String: + return terrain_control_splat_cache_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty]) + + +func _get_terrain_splat_resource_path(tx: int, ty: int) -> String: + return terrain_splat_cache_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty]) + + +func _terrain_splat_queue_size() -> int: + return _terrain_splat_cache_tasks.size() + _terrain_splat_tasks.size() + _terrain_splat_result_queue.size() + + +func _state_has_applicable_quality_terrain(state: Dictionary, cam_pos: Vector3) -> bool: + var quality_mesh: Mesh = state.get("quality_terrain_mesh", null) + if quality_mesh == null: + return false + var quality_source := String(state.get("quality_terrain_source", "")) + if quality_source == "control_splat_cache": + return _tile_within_control_splat_terrain_radius(state, cam_pos) + if quality_source == "splat_cache" or quality_source == "splat": + return _tile_within_splat_terrain_radius(state, cam_pos) + if quality_source == "baked_full": + return _tile_within_full_terrain_radius(state, cam_pos) + return _tile_within_full_terrain_radius(state, cam_pos) + + func _get_baked_tile_mesh(state: Dictionary, lod: int) -> Mesh: var stream_tile: Resource = state.get("stream_tile", null) - if stream_tile != null: - return stream_tile.get("terrain_mesh") var baked_tile: Resource = state.get("baked_tile", null) + if stream_tile != null: + if lod <= 0: + var quality_mesh: Mesh = state.get("quality_terrain_mesh", null) + if quality_mesh != null: + return quality_mesh + return stream_tile.get("terrain_mesh") if baked_tile == null: return null if lod <= 0: @@ -2997,11 +4100,68 @@ func _trim_tile_mesh_cache() -> void: _tile_mesh_cache.erase(oldest) +func _remove_tile_mesh_cache_entries(tile_key: String) -> void: + for lod in range(0, 4): + var cache_key := _tile_mesh_cache_key(tile_key, lod) + _tile_mesh_cache.erase(cache_key) + _tile_mesh_cache_order.erase(cache_key) + + func _clear_tile_mesh_cache() -> void: _tile_mesh_cache.clear() _tile_mesh_cache_order.clear() +func _restore_quality_terrain_mesh(tile_key: String, state: Dictionary) -> Dictionary: + if not _terrain_quality_mesh_cache.has(tile_key): + return state + var cached: Dictionary = _terrain_quality_mesh_cache[tile_key] + var source := String(cached.get("source", "baked_full")) + if source == "control_splat_cache" or source == "splat_cache" or source == "splat": + _terrain_quality_mesh_cache.erase(tile_key) + _terrain_quality_mesh_cache_order.erase(tile_key) + return state + var mesh: Mesh = cached.get("mesh", null) + if mesh == null: + _terrain_quality_mesh_cache.erase(tile_key) + _terrain_quality_mesh_cache_order.erase(tile_key) + return state + state["quality_terrain_mesh"] = mesh + state["quality_terrain_source"] = source + _touch_quality_terrain_mesh_cache(tile_key) + return state + + +func _store_quality_terrain_mesh(tile_key: String, mesh: Mesh, source: String) -> void: + if tile_key.is_empty() or mesh == null: + return + if source == "control_splat_cache" or source == "splat_cache" or source == "splat": + return + _terrain_quality_mesh_cache[tile_key] = { + "mesh": mesh, + "source": source, + } + _touch_quality_terrain_mesh_cache(tile_key) + _trim_quality_terrain_mesh_cache() + + +func _touch_quality_terrain_mesh_cache(tile_key: String) -> void: + _terrain_quality_mesh_cache_order.erase(tile_key) + _terrain_quality_mesh_cache_order.append(tile_key) + + +func _trim_quality_terrain_mesh_cache() -> void: + var limit := maxi(0, terrain_quality_mesh_cache_limit) + while _terrain_quality_mesh_cache_order.size() > limit: + var oldest: String = _terrain_quality_mesh_cache_order.pop_front() + _terrain_quality_mesh_cache.erase(oldest) + + +func _clear_quality_terrain_mesh_cache() -> void: + _terrain_quality_mesh_cache.clear() + _terrain_quality_mesh_cache_order.clear() + + func _position_camera_over_world() -> void: if _camera_initialized: return @@ -3056,9 +4216,10 @@ func _set_editor_owner_recursive(node: Node) -> void: func _make_editor_signature() -> String: - return "%s|%s|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%.3f|%.3f|%.3f|%.3f|%.3f|%s|%s|%s|%s" % [ + var values := [ extracted_dir, map_name, + quality_preset, 1 if editor_preview_enabled else 0, editor_preview_center_x, editor_preview_center_y, @@ -3072,6 +4233,20 @@ func _make_editor_signature() -> String: prewarm_tile_margin, 1 if prewarm_tile_lod_instances else 0, retain_tile_margin, + 1 if terrain_full_quality_enabled else 0, + terrain_full_quality_radius_tiles, + max_concurrent_terrain_upgrade_tasks, + 1 if terrain_control_splat_quality_enabled else 0, + 1 if terrain_control_splat_primary else 0, + 1 if terrain_control_splat_hide_fallback else 0, + terrain_control_splat_hide_fallback_radius_tiles, + terrain_control_splat_radius_tiles, + max_active_terrain_control_splat_tiles, + terrain_control_splat_cache_dir, + 1 if terrain_splat_quality_enabled else 0, + terrain_splat_radius_tiles, + max_active_terrain_splat_tiles, + max_concurrent_terrain_splat_tasks, 1 if enable_m2_assets else 0, 1 if enable_m2_placeholders else 0, m2_multimesh_batch_size, @@ -3086,11 +4261,17 @@ func _make_editor_signature() -> String: wmo_visibility_range, full_lod_prewarm_tiles, boundary_prefetch_threshold, + 1 if terrain_quality_log_enabled else 0, + terrain_quality_log_interval, m2_cache_dir, wmo_cache_dir, wmo_render_cache_dir, streaming_tile_cache_dir, ] + var parts := PackedStringArray() + for value in values: + parts.append(str(value)) + return "|".join(parts) func _release_tile_if_unused(key: String) -> void: diff --git a/src/tools/build_adt_control_splat_cache.gd b/src/tools/build_adt_control_splat_cache.gd new file mode 100644 index 0000000..d7b1b60 --- /dev/null +++ b/src/tools/build_adt_control_splat_cache.gd @@ -0,0 +1,263 @@ +extends SceneTree + +## Builds ADT terrain resources using one Texture2DArray/control-map material per tile. +## +## Usage: +## godot --headless --path --script res://src/tools/build_adt_control_splat_cache.gd -- \ +## --map Azeroth --jobs auto --texture-size 256 --lod 0 --force + +const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd") +const CONTROL_SPLAT_TILE_SCRIPT := preload("res://src/resources/control_splat_adt_tile.gd") + +var _builder +var _loader +var _image_cache: Dictionary = {} + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var map_name := _get_arg_value(args, "--map", "Azeroth") + var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted")) + var output_dir := _normalize_res_path(_get_arg_value(args, "--output", "res://data/cache/terrain_control_splat_v3")) + var lod := clampi(_get_arg_value(args, "--lod", "0").to_int(), 0, 3) + var texture_size := clampi(_get_arg_value(args, "--texture-size", "256").to_int(), 16, 1024) + var worker_index: Variant = _get_optional_int_arg(args, "--worker-index") + var worker_count := maxi(1, _get_arg_value(args, "--worker-count", "1").to_int()) + var tile_x: Variant = _get_optional_int_arg(args, "--tile-x") + var tile_y: Variant = _get_optional_int_arg(args, "--tile-y") + var default_jobs := "1" if tile_x != null or tile_y != null else "auto" + var jobs := _parse_jobs_arg(_get_arg_value(args, "--jobs", default_jobs)) + var force := args.has("--force") + + if jobs > 1 and worker_index == null: + _run_parallel_controller(args, jobs) + return + + _builder = ADT_BUILDER_SCRIPT.new() + if not ClassDB.class_exists("ADTLoader"): + push_error("ADTLoader not found. Rebuild GDExtension first.") + quit(1) + return + + _loader = ClassDB.instantiate("ADTLoader") + if _loader == null: + push_error("Failed to instantiate ADTLoader.") + quit(1) + return + + var map_dir := extracted_dir.path_join("World/Maps/%s" % map_name) + var dir := DirAccess.open(map_dir) + if dir == null: + push_error("Cannot open map directory: %s" % map_dir) + quit(1) + return + + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir.path_join(map_name))) + + var built := 0 + var skipped := 0 + var failed := 0 + var tile_index := 0 + var started_ms := Time.get_ticks_msec() + var files := dir.get_files() + var total := _count_worker_tiles(files, map_name, tile_x, tile_y, worker_index, worker_count) + print("Control splat cache started. map=%s tiles=%d lod=%d texture_size=%d" % [ + map_name, total, lod, texture_size]) + + for file_name in files: + if not file_name.ends_with(".adt"): + continue + + var stem := file_name.trim_suffix(".adt") + var parts := stem.split("_") + if parts.size() != 3 or parts[0] != map_name: + continue + if not parts[1].is_valid_int() or not parts[2].is_valid_int(): + continue + + var tx := parts[1].to_int() + var ty := parts[2].to_int() + if tile_x != null and tx != tile_x: + continue + if tile_y != null and ty != tile_y: + continue + if worker_index != null: + if tile_index % worker_count != int(worker_index): + tile_index += 1 + continue + tile_index += 1 + + var output_res_path := output_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty]) + var output_abs_path := ProjectSettings.globalize_path(output_res_path) + if not force and FileAccess.file_exists(output_abs_path): + skipped += 1 + continue + + var source_res_path := map_dir.path_join(file_name) + var source_abs_path := ProjectSettings.globalize_path(source_res_path) + var data: Dictionary = _loader.call("load_adt", source_abs_path) + if data.is_empty() or not data.has("chunks"): + push_warning("Control splat failed to parse ADT: %s" % source_res_path) + failed += 1 + continue + + var payload: Dictionary = _builder.build_control_splat_tile_render_payload( + data, + _image_cache, + ProjectSettings.globalize_path(extracted_dir), + lod, + texture_size) + var mesh: Mesh = payload.get("mesh", null) + var texture_images: Array = payload.get("texture_images", []) + if mesh == null or texture_images.is_empty(): + push_warning("Control splat produced incomplete tile: %s" % source_res_path) + failed += 1 + continue + var array_mesh := mesh as ArrayMesh + if array_mesh != null and array_mesh.get_surface_count() > 0: + array_mesh.surface_set_material(0, null) + + var tile: Resource = CONTROL_SPLAT_TILE_SCRIPT.new() + tile.set("format_version", CONTROL_SPLAT_TILE_SCRIPT.FORMAT_VERSION) + tile.set("map_name", map_name) + tile.set("tile_x", tx) + tile.set("tile_y", ty) + tile.set("tile_origin", _builder.get_tile_origin_for_data(data)) + tile.set("lod", lod) + tile.set("texture_size", texture_size) + tile.set("terrain_mesh", mesh) + tile.set("texture_images", texture_images) + tile.set("alpha_atlas", payload.get("alpha_atlas", null)) + tile.set("layer_index_map", payload.get("layer_index_map", null)) + tile.set("texture_names", payload.get("texture_names", PackedStringArray())) + + var save_err := ResourceSaver.save(tile, output_res_path) + if save_err != OK: + push_warning("Failed to save control splat tile: %s (err=%d)" % [output_res_path, save_err]) + failed += 1 + continue + + built += 1 + if built % 25 == 0: + print("Built %d control splat tiles..." % built) + + var elapsed_ms := Time.get_ticks_msec() - started_ms + print("Control splat cache finished. built=%d skipped=%d failed=%d elapsed=%.2fs" % [ + built, skipped, failed, float(elapsed_ms) / 1000.0]) + quit(0 if failed == 0 else 2) + + +func _run_parallel_controller(args: PackedStringArray, jobs: int) -> void: + var started_ms := Time.get_ticks_msec() + var threads: Array[Thread] = [] + print("Starting parallel ADT control splat cache: jobs=%d" % jobs) + for worker_index in jobs: + var thread := Thread.new() + var err := thread.start(_run_worker_process.bind(args, worker_index, jobs)) + if err != OK: + push_error("Failed to start control splat worker thread %d (err=%d)" % [worker_index, err]) + quit(1) + return + threads.append(thread) + + var failed := 0 + for thread in threads: + var exit_code: int = int(thread.wait_to_finish()) + if exit_code != 0: + failed += 1 + + var elapsed_ms := Time.get_ticks_msec() - started_ms + print("Parallel control splat cache finished. jobs=%d failed_workers=%d elapsed=%.2fs" % [ + jobs, failed, float(elapsed_ms) / 1000.0]) + quit(0 if failed == 0 else 2) + + +func _run_worker_process(args: PackedStringArray, worker_index: int, worker_count: int) -> int: + var worker_args := PackedStringArray([ + "--headless", + "--path", + ProjectSettings.globalize_path("res://"), + "--script", + "res://src/tools/build_adt_control_splat_cache.gd", + "--", + ]) + + var skip_next := false + for i in args.size(): + if skip_next: + skip_next = false + continue + var arg := args[i] + if arg == "--jobs" or arg == "--worker-index" or arg == "--worker-count": + skip_next = i + 1 < args.size() + continue + worker_args.append(arg) + + worker_args.append("--jobs") + worker_args.append("1") + worker_args.append("--worker-index") + worker_args.append(str(worker_index)) + worker_args.append("--worker-count") + worker_args.append(str(worker_count)) + + print("Control splat worker %d/%d started" % [worker_index + 1, worker_count]) + var exit_code := OS.execute(OS.get_executable_path(), worker_args) + print("Control splat worker %d/%d finished with exit=%d" % [worker_index + 1, worker_count, exit_code]) + return exit_code + + +func _count_worker_tiles( + files: PackedStringArray, + map_name: String, + tile_x: Variant, + tile_y: Variant, + worker_index: Variant, + worker_count: int) -> int: + + var tile_index := 0 + var count := 0 + for file_name in files: + if not file_name.ends_with(".adt"): + continue + var stem := file_name.trim_suffix(".adt") + var parts := stem.split("_") + if parts.size() != 3 or parts[0] != map_name: + continue + if not parts[1].is_valid_int() or not parts[2].is_valid_int(): + continue + var tx := parts[1].to_int() + var ty := parts[2].to_int() + if tile_x != null and tx != tile_x: + continue + if tile_y != null and ty != tile_y: + continue + if worker_index != null and tile_index % worker_count != int(worker_index): + tile_index += 1 + continue + tile_index += 1 + count += 1 + return count + + +func _parse_jobs_arg(value: String) -> int: + if value.to_lower() == "auto": + return maxi(1, OS.get_processor_count() - 1) + return maxi(1, value.to_int()) + + +func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String: + var idx := args.find(name) + if idx >= 0 and idx + 1 < args.size(): + return args[idx + 1] + return default_value + + +func _get_optional_int_arg(args: PackedStringArray, name: String) -> Variant: + var idx := args.find(name) + if idx >= 0 and idx + 1 < args.size() and args[idx + 1].is_valid_int(): + return args[idx + 1].to_int() + return null + + +func _normalize_res_path(path: String) -> String: + return path.rstrip("/") diff --git a/src/tools/build_adt_control_splat_cache.gd.uid b/src/tools/build_adt_control_splat_cache.gd.uid new file mode 100644 index 0000000..7bb2499 --- /dev/null +++ b/src/tools/build_adt_control_splat_cache.gd.uid @@ -0,0 +1 @@ +uid://c3vxox0uwyyx7 diff --git a/src/tools/build_adt_splat_cache.gd b/src/tools/build_adt_splat_cache.gd new file mode 100644 index 0000000..61c8aa4 --- /dev/null +++ b/src/tools/build_adt_splat_cache.gd @@ -0,0 +1,377 @@ +extends SceneTree + +## Builds lightweight terrain-splat resources for near-camera quality. +## +## Usage: +## godot --headless --path --script res://src/tools/build_adt_splat_cache.gd -- \ +## --map Azeroth --jobs auto --lod 0 --force + +const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd") +const SPLAT_TILE_SCRIPT := preload("res://src/resources/splat_adt_tile.gd") +const PROGRESS_DIR := "res://data/cache/splat_progress" +const PROGRESS_REPORT_INTERVAL_MS := 2000 + +var _builder +var _loader +var _tex_cache: Dictionary = {} +var _progress_path := "" +var _progress_worker_index := -1 +var _progress_worker_count := 1 +var _progress_total := 0 +var _progress_processed := 0 +var _progress_built := 0 +var _progress_skipped := 0 +var _progress_failed := 0 +var _progress_last_tile := "" + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var map_name := _get_arg_value(args, "--map", "Azeroth") + var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted")) + var output_dir := _normalize_res_path(_get_arg_value(args, "--output", "res://data/cache/terrain_splat_v1")) + var lod := clampi(_get_arg_value(args, "--lod", "0").to_int(), 0, 3) + var worker_index: Variant = _get_optional_int_arg(args, "--worker-index") + var worker_count := maxi(1, _get_arg_value(args, "--worker-count", "1").to_int()) + var tile_x: Variant = _get_optional_int_arg(args, "--tile-x") + var tile_y: Variant = _get_optional_int_arg(args, "--tile-y") + var default_jobs := "1" if tile_x != null or tile_y != null else "auto" + var jobs := _parse_jobs_arg(_get_arg_value(args, "--jobs", default_jobs)) + var force := args.has("--force") + + if jobs > 1 and worker_index == null: + _run_parallel_controller(args, jobs) + return + + _builder = ADT_BUILDER_SCRIPT.new() + if not ClassDB.class_exists("ADTLoader"): + push_error("ADTLoader not found. Rebuild GDExtension first.") + quit(1) + return + + _loader = ClassDB.instantiate("ADTLoader") + if _loader == null: + push_error("Failed to instantiate ADTLoader.") + quit(1) + return + + var map_dir := extracted_dir.path_join("World/Maps/%s" % map_name) + var dir := DirAccess.open(map_dir) + if dir == null: + push_error("Cannot open map directory: %s" % map_dir) + quit(1) + return + + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir.path_join(map_name))) + + var built := 0 + var skipped := 0 + var failed := 0 + var started_ms := Time.get_ticks_msec() + var tile_index := 0 + var worker_total := _count_worker_tiles(dir.get_files(), map_name, tile_x, tile_y, worker_index, worker_count) + _progress_setup(worker_index, worker_count, worker_total) + + for file_name in dir.get_files(): + if not file_name.ends_with(".adt"): + continue + + var stem := file_name.trim_suffix(".adt") + var parts := stem.split("_") + if parts.size() != 3 or parts[0] != map_name: + continue + if not parts[1].is_valid_int() or not parts[2].is_valid_int(): + continue + + var tx := parts[1].to_int() + var ty := parts[2].to_int() + if tile_x != null and tx != tile_x: + continue + if tile_y != null and ty != tile_y: + continue + if worker_index != null: + if tile_index % worker_count != int(worker_index): + tile_index += 1 + continue + tile_index += 1 + + var output_res_path := output_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty]) + var output_abs_path := ProjectSettings.globalize_path(output_res_path) + if not force and FileAccess.file_exists(output_abs_path): + skipped += 1 + _progress_update(tx, ty, built, skipped, failed) + continue + + var source_res_path := map_dir.path_join(file_name) + var source_abs_path := ProjectSettings.globalize_path(source_res_path) + var data: Dictionary = _loader.call("load_adt", source_abs_path) + if data.is_empty() or not data.has("chunks"): + push_warning("Splat cache failed to parse ADT: %s" % source_res_path) + failed += 1 + _progress_update(tx, ty, built, skipped, failed) + continue + + var payload: Dictionary = _builder.build_tile_coarse_render_payload( + data, + _tex_cache, + ProjectSettings.globalize_path(extracted_dir), + lod) + var mesh: Mesh = payload.get("mesh", null) + if mesh == null: + push_warning("Splat cache produced empty mesh: %s" % source_res_path) + failed += 1 + _progress_update(tx, ty, built, skipped, failed) + continue + + var splat_tile: Resource = SPLAT_TILE_SCRIPT.new() + splat_tile.set("format_version", SPLAT_TILE_SCRIPT.FORMAT_VERSION) + splat_tile.set("map_name", map_name) + splat_tile.set("tile_x", tx) + splat_tile.set("tile_y", ty) + splat_tile.set("tile_origin", _builder.get_tile_origin_for_data(data)) + splat_tile.set("lod", lod) + splat_tile.set("terrain_mesh", mesh) + + var save_err := ResourceSaver.save(splat_tile, output_res_path) + if save_err != OK: + push_warning("Failed to save splat tile: %s (err=%d)" % [output_res_path, save_err]) + failed += 1 + _progress_update(tx, ty, built, skipped, failed) + continue + + built += 1 + _progress_update(tx, ty, built, skipped, failed) + if built % 25 == 0: + print("Built %d splat tiles..." % built) + + var elapsed_ms := Time.get_ticks_msec() - started_ms + print("Splat cache finished. built=%d skipped=%d failed=%d elapsed=%.2fs" % [ + built, skipped, failed, float(elapsed_ms) / 1000.0]) + _progress_finish(built, skipped, failed) + quit(0 if failed == 0 else 2) + + +func _run_parallel_controller(args: PackedStringArray, jobs: int) -> void: + var started_ms := Time.get_ticks_msec() + var last_report_ms := 0 + var threads: Array[Thread] = [] + _prepare_progress_dir() + + print("Starting parallel ADT splat cache: jobs=%d" % jobs) + for worker_index in jobs: + var thread := Thread.new() + var err := thread.start(_run_worker_process.bind(args, worker_index, jobs)) + if err != OK: + push_error("Failed to start splat worker thread %d (err=%d)" % [worker_index, err]) + quit(1) + return + threads.append(thread) + + var finished := false + while not finished: + OS.delay_msec(250) + var now := Time.get_ticks_msec() + if now - last_report_ms >= PROGRESS_REPORT_INTERVAL_MS: + _print_parallel_progress(jobs, started_ms) + last_report_ms = now + finished = true + for thread in threads: + if thread.is_alive(): + finished = false + break + + var failed := 0 + for thread in threads: + var exit_code: int = int(thread.wait_to_finish()) + if exit_code != 0: + failed += 1 + + var elapsed_ms := Time.get_ticks_msec() - started_ms + _print_parallel_progress(jobs, started_ms) + print("Parallel splat cache finished. jobs=%d failed_workers=%d elapsed=%.2fs" % [ + jobs, failed, float(elapsed_ms) / 1000.0]) + quit(0 if failed == 0 else 2) + + +func _run_worker_process(args: PackedStringArray, worker_index: int, worker_count: int) -> int: + var worker_args := PackedStringArray([ + "--headless", + "--path", + ProjectSettings.globalize_path("res://"), + "--script", + "res://src/tools/build_adt_splat_cache.gd", + "--", + ]) + + var skip_next := false + for i in args.size(): + if skip_next: + skip_next = false + continue + var arg := args[i] + if arg == "--jobs" or arg == "--worker-index" or arg == "--worker-count": + skip_next = i + 1 < args.size() + continue + worker_args.append(arg) + + worker_args.append("--jobs") + worker_args.append("1") + worker_args.append("--worker-index") + worker_args.append(str(worker_index)) + worker_args.append("--worker-count") + worker_args.append(str(worker_count)) + + print("Splat worker %d/%d started" % [worker_index + 1, worker_count]) + var exit_code := OS.execute(OS.get_executable_path(), worker_args) + print("Splat worker %d/%d finished with exit=%d" % [worker_index + 1, worker_count, exit_code]) + return exit_code + + +func _prepare_progress_dir() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(PROGRESS_DIR)) + var dir := DirAccess.open(PROGRESS_DIR) + if dir == null: + return + for file_name in dir.get_files(): + if file_name.begins_with("splat_worker_") and file_name.ends_with(".json"): + dir.remove(file_name) + + +func _progress_setup(worker_index: Variant, worker_count: int, total: int) -> void: + _progress_worker_index = int(worker_index) if worker_index != null else 0 + _progress_worker_count = worker_count + _progress_total = total + _progress_processed = 0 + _progress_built = 0 + _progress_skipped = 0 + _progress_failed = 0 + _progress_last_tile = "" + _progress_path = PROGRESS_DIR.path_join("splat_worker_%d.json" % _progress_worker_index) + _progress_write(false) + + +func _progress_update(tx: int, ty: int, built: int, skipped: int, failed: int) -> void: + _progress_processed += 1 + _progress_built = built + _progress_skipped = skipped + _progress_failed = failed + _progress_last_tile = "%d_%d" % [tx, ty] + _progress_write(false) + + +func _progress_finish(built: int, skipped: int, failed: int) -> void: + _progress_built = built + _progress_skipped = skipped + _progress_failed = failed + _progress_processed = _progress_total + _progress_write(true) + + +func _progress_write(done: bool) -> void: + if _progress_path.is_empty(): + return + var file := FileAccess.open(_progress_path, FileAccess.WRITE) + if file == null: + return + file.store_string(JSON.stringify({ + "worker": _progress_worker_index, + "workers": _progress_worker_count, + "total": _progress_total, + "processed": _progress_processed, + "built": _progress_built, + "skipped": _progress_skipped, + "failed": _progress_failed, + "last_tile": _progress_last_tile, + "done": done, + })) + + +func _print_parallel_progress(jobs: int, started_ms: int) -> void: + var now := Time.get_ticks_msec() + var total := 0 + var processed := 0 + var built := 0 + var skipped := 0 + var failed := 0 + var done := 0 + for worker_index in jobs: + var path := PROGRESS_DIR.path_join("splat_worker_%d.json" % worker_index) + if not FileAccess.file_exists(path): + continue + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + continue + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + continue + var row: Dictionary = parsed + total += int(row.get("total", 0)) + processed += int(row.get("processed", 0)) + built += int(row.get("built", 0)) + skipped += int(row.get("skipped", 0)) + failed += int(row.get("failed", 0)) + if bool(row.get("done", false)): + done += 1 + + var elapsed := maxf(0.001, float(now - started_ms) / 1000.0) + var rate := float(processed) / elapsed + print("Progress: %d/%d tiles, built=%d skipped=%d failed=%d, workers done=%d/%d, %.2f tiles/s" % [ + processed, total, built, skipped, failed, done, jobs, rate]) + + +func _count_worker_tiles( + files: PackedStringArray, + map_name: String, + tile_x: Variant, + tile_y: Variant, + worker_index: Variant, + worker_count: int) -> int: + var count := 0 + var tile_index := 0 + for file_name in files: + if not file_name.ends_with(".adt"): + continue + var parts := file_name.trim_suffix(".adt").split("_") + if parts.size() != 3 or parts[0] != map_name: + continue + if not parts[1].is_valid_int() or not parts[2].is_valid_int(): + continue + var tx := parts[1].to_int() + var ty := parts[2].to_int() + if tile_x != null and tx != tile_x: + continue + if tile_y != null and ty != tile_y: + continue + if worker_index != null and tile_index % worker_count != int(worker_index): + tile_index += 1 + continue + tile_index += 1 + count += 1 + return count + + +func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String: + var index := args.find(name) + if index >= 0 and index + 1 < args.size(): + return args[index + 1] + return default_value + + +func _get_optional_int_arg(args: PackedStringArray, name: String): + var index := args.find(name) + if index >= 0 and index + 1 < args.size() and args[index + 1].is_valid_int(): + return args[index + 1].to_int() + return null + + +func _parse_jobs_arg(value: String) -> int: + var normalized := value.strip_edges().to_lower() + if normalized == "auto": + return clampi(OS.get_processor_count() - 2, 1, 8) + return maxi(1, normalized.to_int()) + + +func _normalize_res_path(path: String) -> String: + if path.begins_with("res://"): + return path + return "res://%s" % path.trim_prefix("./").trim_prefix("/") diff --git a/src/tools/build_adt_splat_cache.gd.uid b/src/tools/build_adt_splat_cache.gd.uid new file mode 100644 index 0000000..f2ce9bc --- /dev/null +++ b/src/tools/build_adt_splat_cache.gd.uid @@ -0,0 +1 @@ +uid://cr7v1un5okd3p