diff --git a/.gitignore b/.gitignore index 5be889c..0b7cb34 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ # Offline WMO scene cache /data/cache/wmo_tscn/ +# Offline WMO render cache +/data/cache/wmo_render_v1/ + # Root cache dir (генерируется скриптом) /cache/ diff --git a/addons/mpq_extractor/loaders/adt_builder.gd b/addons/mpq_extractor/loaders/adt_builder.gd index 7941507..58b1c30 100644 --- a/addons/mpq_extractor/loaders/adt_builder.gd +++ b/addons/mpq_extractor/loaders/adt_builder.gd @@ -11,6 +11,7 @@ const USE_GEOMETRY_NORMALS := true static var _terrain_shader: Shader static var _single_texture_shader: Shader static var _empty_alpha_texture: Texture2D +static var _native_adt_baker: Object static var _fallback_material: StandardMaterial3D static var _single_texture_material_cache: Dictionary = {} static var _baked_texture_material_cache: Dictionary = {} @@ -109,6 +110,24 @@ static func build_baked_tile_render_payload( return _build_baked_tile_payload(data, image_cache, extracted_dir, lod, texture_size) +static func build_baked_tile_render_payload_pair( + data: Dictionary, + image_cache: Dictionary, + extracted_dir: String = "", + full_lod: int = 0, + full_texture_size: int = 2048, + coarse_lod: int = 3, + coarse_texture_size: int = 512) -> Dictionary: + return _build_baked_tile_payload_pair( + data, + image_cache, + extracted_dir, + full_lod, + full_texture_size, + coarse_lod, + coarse_texture_size) + + static func get_tile_origin(data: Dictionary) -> Vector3: var found := false var min_x := 0.0 @@ -441,6 +460,48 @@ static func _build_baked_tile_payload( lod: int, texture_size: int) -> Dictionary: + var payload := _build_baked_tile_geometry_payload(data, lod) + if payload.is_empty(): + return {} + + var baked_image := _build_tile_baked_albedo(data, image_cache, extracted_dir, texture_size) + _apply_baked_albedo_material(payload.get("mesh", null), baked_image) + return payload + + +static func _build_baked_tile_payload_pair( + data: Dictionary, + image_cache: Dictionary, + extracted_dir: String, + full_lod: int, + full_texture_size: int, + coarse_lod: int, + coarse_texture_size: int) -> Dictionary: + + var full_payload := _build_baked_tile_geometry_payload(data, full_lod) + if full_payload.is_empty(): + return {} + + var coarse_payload := _build_baked_tile_geometry_payload(data, coarse_lod) + var full_image := _build_tile_baked_albedo(data, image_cache, extracted_dir, full_texture_size) + var coarse_image: Image = null + if full_image != null: + coarse_image = full_image.duplicate() + var coarse_size := _get_baked_albedo_size(coarse_texture_size) + if coarse_image.get_width() != coarse_size or coarse_image.get_height() != coarse_size: + coarse_image.resize(coarse_size, coarse_size, Image.INTERPOLATE_BILINEAR) + + _apply_baked_albedo_material(full_payload.get("mesh", null), full_image) + if not coarse_payload.is_empty(): + _apply_baked_albedo_material(coarse_payload.get("mesh", null), coarse_image) + + return { + "full": full_payload, + "coarse": coarse_payload, + } + + +static func _build_baked_tile_geometry_payload(data: Dictionary, lod: int) -> Dictionary: var tile_origin := get_tile_origin(data) var verts := PackedVector3Array() var nrms := PackedVector3Array() @@ -491,7 +552,16 @@ static func _build_baked_tile_payload( var mesh := ArrayMesh.new() mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) - var baked_image := _build_tile_baked_albedo(data, image_cache, extracted_dir, texture_size) + return { + "mesh": mesh, + "position": Vector3.ZERO, + "name": "TileLOD%d" % lod, + } + + +static func _apply_baked_albedo_material(mesh: Mesh, baked_image: Image) -> void: + if mesh == null: + return if baked_image: baked_image.generate_mipmaps() var baked_texture := ImageTexture.create_from_image(baked_image) @@ -499,12 +569,6 @@ static func _build_baked_tile_payload( else: mesh.surface_set_material(0, _build_fallback_material()) - return { - "mesh": mesh, - "position": Vector3.ZERO, - "name": "TileLOD%d" % lod, - } - static func _build_mesh_instance_from_payload(payload: Dictionary) -> MeshInstance3D: if payload.is_empty(): @@ -881,7 +945,15 @@ static func _build_tile_baked_albedo( extracted_dir: String, texture_size: int) -> Image: - var chunk_pixels := maxi(8, int(ceil(float(maxi(texture_size, 16)) / 16.0))) + var native_image := _build_tile_baked_albedo_native( + data, + image_cache, + extracted_dir, + texture_size) + if native_image != null: + return native_image + + var chunk_pixels := int(_get_baked_albedo_size(texture_size) / 16) var size := chunk_pixels * 16 var image := Image.create(size, size, false, Image.FORMAT_RGB8) image.fill(Color(0.4, 0.55, 0.3)) @@ -914,6 +986,30 @@ static func _build_tile_baked_albedo( return image +static func _build_tile_baked_albedo_native( + data: Dictionary, + image_cache: Dictionary, + extracted_dir: String, + texture_size: int) -> Image: + + if not ClassDB.class_exists("ADTBaker"): + return null + if _native_adt_baker == null: + _native_adt_baker = ClassDB.instantiate("ADTBaker") + if _native_adt_baker == null: + return null + return _native_adt_baker.call( + "bake_tile_albedo", + data, + image_cache, + extracted_dir, + texture_size) as Image + + +static func _get_baked_albedo_size(texture_size: int) -> int: + return maxi(8, int(ceil(float(maxi(texture_size, 16)) / 16.0))) * 16 + + static func _sample_chunk_baked_albedo( chunk: Dictionary, tex_names: PackedStringArray, @@ -1110,6 +1206,7 @@ static func _load_texture( cache[blp_path] = null return null + img.generate_mipmaps() var tex := ImageTexture.create_from_image(img) cache[blp_path] = tex return tex diff --git a/addons/mpq_extractor/loaders/wmo_builder.gd b/addons/mpq_extractor/loaders/wmo_builder.gd index e6a1eb4..43147b8 100644 --- a/addons/mpq_extractor/loaders/wmo_builder.gd +++ b/addons/mpq_extractor/loaders/wmo_builder.gd @@ -7,6 +7,8 @@ class_name WMOBuilder const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_RIGHT_YAW_OFFSET := PI * 0.5 +const BUILD_OCCLUDERS := false +const OCCLUDER_MIN_TRIANGLES := 16 static var _texture_cache: Dictionary = {} # rel_path → Node3D prototype (built once, duplicated per usage) @@ -45,6 +47,7 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: # Build one MeshInstance3D per group, plus optional liquid mesh as sibling var groups: Array = data.get("groups", []) + var occluder_root: Node3D = null for gi in groups.size(): var g: Dictionary = groups[gi] if g.is_empty(): @@ -53,6 +56,16 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: mesh_inst.name = "Group_%d" % gi root.add_child(mesh_inst) + if BUILD_OCCLUDERS: + var occluder_inst := _build_group_occluder(g, materials) + if occluder_inst != null: + if occluder_root == null: + occluder_root = Node3D.new() + occluder_root.name = "Occluders" + root.add_child(occluder_root) + occluder_inst.name = "Group_%d_Occluder" % gi + occluder_root.add_child(occluder_inst) + var liquid_dict: Variant = g.get("liquid", null) if liquid_dict is Dictionary and not (liquid_dict as Dictionary).is_empty(): var liquid_inst := _build_group_liquid(liquid_dict) @@ -67,8 +80,7 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: return root -# Instantiates M2 doodads for set 0. Each placement is a M2 prototype -# duplicated under the WMO root with a local Transform3D. +# Instantiates M2 doodads for set 0 as one MultiMesh per M2 path. static func _build_default_doodads(root: Node3D, data: Dictionary, extracted_dir: String) -> void: @@ -83,10 +95,7 @@ static func _build_default_doodads(root: Node3D, data: Dictionary, if count <= 0: return - var doodad_root := Node3D.new() - doodad_root.name = "Doodads" - root.add_child(doodad_root) - + var groups: Dictionary = {} var end: int = mini(start + count, placements.size()) for i in range(start, end): var p: Dictionary = placements[i] @@ -97,22 +106,53 @@ static func _build_default_doodads(root: Node3D, data: Dictionary, if rel_path.ends_with(".mdx") or rel_path.ends_with(".mdl"): rel_path = rel_path.get_basename() + ".m2" - var prototype: Node3D = _get_or_load_m2_prototype(rel_path, extracted_dir) - if prototype == null: - continue - - var inst := prototype.duplicate() as Node3D - if inst == null: - continue - inst.name = "%s_%d" % [rel_path.get_file().get_basename(), i] - var pos: Vector3 = p.get("pos", Vector3.ZERO) var rot_q: Quaternion = p.get("rot", Quaternion.IDENTITY) var scl: float = float(p.get("scale", 1.0)) var basis := (Basis(rot_q) * Basis(Vector3.UP, M2_RIGHT_YAW_OFFSET)).scaled(Vector3.ONE * maxf(scl, 0.001)) - inst.transform = Transform3D(basis, pos) + var xform := Transform3D(basis, pos) + if not groups.has(rel_path): + groups[rel_path] = [] + (groups[rel_path] as Array).append(xform) + + if groups.is_empty(): + return + + var doodad_root := Node3D.new() + doodad_root.name = "Doodads" + root.add_child(doodad_root) + + for rel_path in groups.keys(): + var mesh := _get_or_load_m2_mesh(rel_path, extracted_dir) + if mesh == null: + continue + var transforms: Array = groups[rel_path] + var mm := MultiMesh.new() + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.mesh = mesh + mm.instance_count = transforms.size() + for i in transforms.size(): + mm.set_instance_transform(i, transforms[i]) + + var inst := MultiMeshInstance3D.new() + inst.name = rel_path.get_file().get_basename() + inst.multimesh = mm + inst.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF doodad_root.add_child(inst) + if doodad_root.get_child_count() == 0: + doodad_root.queue_free() + + +static func _get_or_load_m2_mesh(rel_path: String, extracted_dir: String) -> Mesh: + var prototype: Node3D = _get_or_load_m2_prototype(rel_path, extracted_dir) + if prototype == null: + return null + for child in prototype.get_children(): + if child is MeshInstance3D: + return (child as MeshInstance3D).mesh + return null + static func _get_or_load_m2_prototype(rel_path: String, extracted_dir: String) -> Node3D: @@ -178,6 +218,7 @@ static func _build_group_mesh( if verts.is_empty() or indices.is_empty(): var empty := MeshInstance3D.new() empty.mesh = mesh + empty.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF return empty if batches.is_empty(): @@ -227,9 +268,65 @@ static func _build_group_mesh( var mi := MeshInstance3D.new() mi.mesh = mesh + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF return mi +static func _build_group_occluder(g: Dictionary, material_defs: Array) -> OccluderInstance3D: + var verts: PackedVector3Array = g.get("vertices", PackedVector3Array()) + var indices: PackedInt32Array = g.get("indices", PackedInt32Array()) + if verts.is_empty() or indices.is_empty(): + return null + + var batches: Array = g.get("batches", []) + var occluder_indices := PackedInt32Array() + if batches.is_empty(): + for i in range(0, indices.size(), 3): + if i + 2 >= indices.size(): + break + occluder_indices.append(indices[i + 0]) + occluder_indices.append(indices[i + 2]) + occluder_indices.append(indices[i + 1]) + else: + for batch_variant in batches: + if not (batch_variant is Dictionary): + continue + var batch: Dictionary = batch_variant + if not _material_can_occlude(int(batch.get("material_id", -1)), material_defs): + continue + var idx_start: int = int(batch.get("index_start", 0)) + var idx_count: int = int(batch.get("index_count", 0)) + if idx_count <= 0 or idx_start >= indices.size(): + continue + for i in range(idx_start, mini(idx_start + idx_count, indices.size()), 3): + if i + 2 >= indices.size(): + break + occluder_indices.append(indices[i + 0]) + occluder_indices.append(indices[i + 2]) + occluder_indices.append(indices[i + 1]) + + if occluder_indices.size() < OCCLUDER_MIN_TRIANGLES * 3: + return null + + var occluder := ArrayOccluder3D.new() + occluder.set_arrays(verts, occluder_indices) + + var inst := OccluderInstance3D.new() + inst.set_occluder(occluder) + return inst + + +static func _material_can_occlude(mat_id: int, material_defs: Array) -> bool: + if mat_id < 0: + return true + if mat_id >= material_defs.size(): + return false + var mat_def: Variant = material_defs[mat_id] + if not (mat_def is Dictionary): + return false + return int((mat_def as Dictionary).get("blend_mode", 0)) == 0 + + static func _build_material( mat_def: Dictionary, textures: PackedStringArray, @@ -342,6 +439,7 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D: var mi := MeshInstance3D.new() mi.mesh = mesh + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF return mi diff --git a/data/cache/bake_logs/.gitingore b/data/cache/bake_logs/.gitingore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/data/cache/bake_logs/.gitingore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file 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 new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..4f5b2af --- /dev/null +++ b/data/cache/bake_logs/wmo_render_cache_20260625_170522.out.log @@ -0,0 +1,410 @@ +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/data/cache/bake_progress/.gitingore b/data/cache/bake_progress/.gitingore new file mode 100644 index 0000000..c96a04f --- /dev/null +++ b/data/cache/bake_progress/.gitingore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/data/cache/bake_progress/adt_worker_0.json b/data/cache/bake_progress/adt_worker_0.json deleted file mode 100644 index b9d4edc..0000000 --- a/data/cache/bake_progress/adt_worker_0.json +++ /dev/null @@ -1 +0,0 @@ -{"baked":77,"done":false,"failed":0,"last_tile":"33_26","processed":77,"skipped":0,"total":172,"worker":0,"workers":4} \ No newline at end of file diff --git a/data/cache/bake_progress/adt_worker_1.json b/data/cache/bake_progress/adt_worker_1.json deleted file mode 100644 index 7d50ddf..0000000 --- a/data/cache/bake_progress/adt_worker_1.json +++ /dev/null @@ -1 +0,0 @@ -{"baked":79,"done":false,"failed":0,"last_tile":"33_35","processed":79,"skipped":0,"total":172,"worker":1,"workers":4} \ No newline at end of file diff --git a/data/cache/bake_progress/adt_worker_2.json b/data/cache/bake_progress/adt_worker_2.json deleted file mode 100644 index d16374d..0000000 --- a/data/cache/bake_progress/adt_worker_2.json +++ /dev/null @@ -1 +0,0 @@ -{"baked":80,"done":false,"failed":0,"last_tile":"33_40","processed":80,"skipped":0,"total":172,"worker":2,"workers":4} \ No newline at end of file diff --git a/data/cache/bake_progress/adt_worker_3.json b/data/cache/bake_progress/adt_worker_3.json deleted file mode 100644 index d43e51d..0000000 --- a/data/cache/bake_progress/adt_worker_3.json +++ /dev/null @@ -1 +0,0 @@ -{"baked":78,"done":false,"failed":0,"last_tile":"33_33","processed":78,"skipped":0,"total":171,"worker":3,"workers":4} \ No newline at end of file diff --git a/reference/open-realm b/reference/open-realm new file mode 160000 index 0000000..dfe316d --- /dev/null +++ b/reference/open-realm @@ -0,0 +1 @@ +Subproject commit dfe316d3b0073a8ac556b60bdea5372d9cd78332 diff --git a/src/native/src/adt_baker.cpp b/src/native/src/adt_baker.cpp new file mode 100644 index 0000000..ee35742 --- /dev/null +++ b/src/native/src/adt_baker.cpp @@ -0,0 +1,243 @@ +#include "adt_baker.h" +#include "blp_loader.h" + +#include +#include +#include + +#include +#include + +using namespace godot; + +namespace { + +constexpr float FALLBACK_R = 0.4f; +constexpr float FALLBACK_G = 0.55f; +constexpr float FALLBACK_B = 0.3f; + +static float clamp01(float v) { + return std::max(0.0f, std::min(1.0f, v)); +} + +static int32_t positive_mod(int32_t value, int32_t mod) { + int32_t result = value % mod; + return result < 0 ? result + mod : result; +} + +static float wrap01(float v) { + return v - std::floor(v); +} + +} // namespace + +Ref ADTBaker::bake_tile_albedo( + const Dictionary &data, + Dictionary image_cache, + const String &extracted_dir, + int32_t texture_size) { + + const int32_t size = get_baked_albedo_size(texture_size); + const int32_t chunk_pixels = size / 16; + if (size <= 0 || chunk_pixels <= 0) { + return Ref(); + } + + PackedByteArray out; + out.resize(size * size * 3); + uint8_t *out_ptr = out.ptrw(); + for (int32_t i = 0; i < size * size; ++i) { + out_ptr[i * 3 + 0] = static_cast(FALLBACK_R * 255.0f); + out_ptr[i * 3 + 1] = static_cast(FALLBACK_G * 255.0f); + out_ptr[i * 3 + 2] = static_cast(FALLBACK_B * 255.0f); + } + + const PackedStringArray tex_names = data.get("textures", PackedStringArray()); + const Array chunks = data.get("chunks", Array()); + + for (int64_t ci = 0; ci < chunks.size(); ++ci) { + const Dictionary chunk = chunks[ci]; + if (chunk.is_empty()) { + continue; + } + + const int32_t chunk_x = int32_t(chunk.get("index_x", -1)); + const int32_t chunk_y = int32_t(chunk.get("index_y", -1)); + if (chunk_x < 0 || chunk_x >= 16 || chunk_y < 0 || chunk_y >= 16) { + continue; + } + + const Array layers = chunk.get("layers", Array()); + if (layers.is_empty()) { + continue; + } + + TextureData textures[4]; + const int32_t layer_count = std::min(int32_t(layers.size()), 4); + for (int32_t li = 0; li < layer_count; ++li) { + const Dictionary layer = layers[li]; + const int32_t tex_id = int32_t(layer.get("texture_id", -1)); + if (tex_id >= 0 && tex_id < tex_names.size()) { + textures[li] = load_texture_data(tex_names[tex_id], image_cache, extracted_dir); + } + } + if (!textures[0].valid) { + continue; + } + + const Array alpha_maps = chunk.get("alpha_maps", Array()); + const int32_t base_x = chunk_x * chunk_pixels; + const int32_t base_y = chunk_y * chunk_pixels; + + for (int32_t py = 0; py < chunk_pixels; ++py) { + const float local_v = (float(py) + 0.5f) / float(chunk_pixels); + const int32_t ay = std::max(0, std::min(63, int32_t(std::floor(local_v * 63.999f)))); + + for (int32_t px = 0; px < chunk_pixels; ++px) { + const float local_u = (float(px) + 0.5f) / float(chunk_pixels); + const int32_t ax = std::max(0, std::min(63, int32_t(std::floor(local_u * 63.999f)))); + + float weights[4] = {1.0f, 0.0f, 0.0f, 0.0f}; + for (int32_t li = 1; li < layer_count; ++li) { + PackedByteArray alpha; + if (li - 1 < alpha_maps.size()) { + alpha = alpha_maps[li - 1]; + } + if (alpha.size() == 64 * 64) { + weights[li] = float(alpha[ay * 64 + ax]) / 255.0f; + } + } + + weights[0] = std::max(0.0f, 1.0f - weights[1] - weights[2] - weights[3]); + const float sum = weights[0] + weights[1] + weights[2] + weights[3]; + if (sum > 0.0f) { + for (float &weight : weights) { + weight /= sum; + } + } + + const float tiled_u = local_u * 8.0f; + const float tiled_v = local_v * 8.0f; + float rgb[3] = {0.0f, 0.0f, 0.0f}; + for (int32_t li = 0; li < layer_count; ++li) { + if (!textures[li].valid || weights[li] <= 0.0f) { + continue; + } + float sample[3]; + sample_image_repeat(textures[li], tiled_u, tiled_v, sample); + rgb[0] += sample[0] * weights[li]; + rgb[1] += sample[1] * weights[li]; + rgb[2] += sample[2] * weights[li]; + } + + const int32_t out_index = ((base_y + py) * size + (base_x + px)) * 3; + out_ptr[out_index + 0] = static_cast(clamp01(rgb[0]) * 255.0f + 0.5f); + out_ptr[out_index + 1] = static_cast(clamp01(rgb[1]) * 255.0f + 0.5f); + out_ptr[out_index + 2] = static_cast(clamp01(rgb[2]) * 255.0f + 0.5f); + } + } + } + + return Image::create_from_data(size, size, false, Image::FORMAT_RGB8, out); +} + +int32_t ADTBaker::get_baked_albedo_size(int32_t texture_size) { + const int32_t safe_size = std::max(texture_size, 16); + const int32_t chunk_pixels = std::max(8, int32_t(std::ceil(float(safe_size) / 16.0f))); + return chunk_pixels * 16; +} + +String ADTBaker::normalize_rel_path(const String &path) { + return path.replace("\\", "/"); +} + +ADTBaker::TextureData ADTBaker::load_texture_data( + const String &blp_path, + Dictionary &image_cache, + const String &extracted_dir) { + + TextureData texture; + if (blp_path.is_empty()) { + return texture; + } + + Ref image; + if (image_cache.has(blp_path)) { + image = image_cache[blp_path]; + } else { + const String rel_path = normalize_rel_path(blp_path); + String abs_path = extracted_dir; + if (!abs_path.ends_with("/") && !abs_path.ends_with("\\")) { + abs_path += "/"; + } + abs_path += rel_path; + + Ref loader; + loader.instantiate(); + image = loader->load_image(abs_path); + image_cache[blp_path] = image; + } + + if (image.is_null() || image->is_empty()) { + return texture; + } + + if (image->get_format() != Image::FORMAT_RGBA8) { + image = image->duplicate(); + image->convert(Image::FORMAT_RGBA8); + } + + texture.image = image; + texture.width = image->get_width(); + texture.height = image->get_height(); + texture.bytes = image->get_data(); + texture.valid = texture.width > 0 && texture.height > 0 && texture.bytes.size() >= texture.width * texture.height * 4; + return texture; +} + +void ADTBaker::sample_image_repeat( + const TextureData &texture, + float u, + float v, + float out_rgb[3]) { + + if (!texture.valid) { + out_rgb[0] = 1.0f; + out_rgb[1] = 1.0f; + out_rgb[2] = 1.0f; + return; + } + + const float fu = wrap01(u); + const float fv = wrap01(v); + const float px = fu * float(texture.width) - 0.5f; + const float py = fv * float(texture.height) - 0.5f; + const int32_t x0 = positive_mod(int32_t(std::floor(px)), texture.width); + const int32_t y0 = positive_mod(int32_t(std::floor(py)), texture.height); + const int32_t x1 = positive_mod(x0 + 1, texture.width); + const int32_t y1 = positive_mod(y0 + 1, texture.height); + const float tx = px - std::floor(px); + const float ty = py - std::floor(py); + + const uint8_t *bytes = texture.bytes.ptr(); + const int32_t i00 = (y0 * texture.width + x0) * 4; + const int32_t i10 = (y0 * texture.width + x1) * 4; + const int32_t i01 = (y1 * texture.width + x0) * 4; + const int32_t i11 = (y1 * texture.width + x1) * 4; + + for (int32_t c = 0; c < 3; ++c) { + const float c00 = float(bytes[i00 + c]) / 255.0f; + const float c10 = float(bytes[i10 + c]) / 255.0f; + const float c01 = float(bytes[i01 + c]) / 255.0f; + const float c11 = float(bytes[i11 + c]) / 255.0f; + const float cx0 = c00 + (c10 - c00) * tx; + const float cx1 = c01 + (c11 - c01) * tx; + out_rgb[c] = cx0 + (cx1 - cx0) * ty; + } +} + +void ADTBaker::_bind_methods() { + ClassDB::bind_method( + D_METHOD("bake_tile_albedo", "data", "image_cache", "extracted_dir", "texture_size"), + &ADTBaker::bake_tile_albedo); +} diff --git a/src/native/src/adt_baker.h b/src/native/src/adt_baker.h new file mode 100644 index 0000000..e0ec907 --- /dev/null +++ b/src/native/src/adt_baker.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace godot { + +class ADTBaker : public RefCounted { + GDCLASS(ADTBaker, RefCounted) + +public: + Ref bake_tile_albedo( + const Dictionary &data, + Dictionary image_cache, + const String &extracted_dir, + int32_t texture_size); + +protected: + static void _bind_methods(); + +private: + struct TextureData { + Ref image; + PackedByteArray bytes; + int32_t width = 0; + int32_t height = 0; + bool valid = false; + }; + + static int32_t get_baked_albedo_size(int32_t texture_size); + static String normalize_rel_path(const String &path); + static TextureData load_texture_data( + const String &blp_path, + Dictionary &image_cache, + const String &extracted_dir); + static void sample_image_repeat( + const TextureData &texture, + float u, + float v, + float out_rgb[3]); +}; + +} // namespace godot diff --git a/src/native/src/register_types.cpp b/src/native/src/register_types.cpp index ca56078..5ee4f8f 100644 --- a/src/native/src/register_types.cpp +++ b/src/native/src/register_types.cpp @@ -2,6 +2,7 @@ #include "mpq_manager.h" #include "wmo_loader.h" #include "adt_loader.h" +#include "adt_baker.h" #include "blp_loader.h" #include "m2_loader.h" #include "wdt_loader.h" @@ -16,6 +17,7 @@ void initialize_mpq_extractor_module(ModuleInitializationLevel p_level) { ClassDB::register_class(); ClassDB::register_class(); ClassDB::register_class(); + ClassDB::register_class(); ClassDB::register_class(); ClassDB::register_class(); ClassDB::register_class(); diff --git a/src/resources/.gitignore b/src/resources/.gitignore index c96a04f..cd7ea44 100644 --- a/src/resources/.gitignore +++ b/src/resources/.gitignore @@ -1,2 +1,3 @@ * -!.gitignore \ No newline at end of file +!.gitignore +!wmo_streaming_resource.gd diff --git a/src/resources/wmo_streaming_resource.gd b/src/resources/wmo_streaming_resource.gd new file mode 100644 index 0000000..4748524 --- /dev/null +++ b/src/resources/wmo_streaming_resource.gd @@ -0,0 +1,13 @@ +extends Resource +class_name WMOStreamingResource + +const FORMAT_VERSION := 1 + +@export var format_version: int = FORMAT_VERSION +@export var source_path: String = "" +@export var mesh_names: PackedStringArray = PackedStringArray() +@export var meshes: Array[Mesh] = [] +@export var mesh_transforms: Array[Transform3D] = [] +@export var multimesh_names: PackedStringArray = PackedStringArray() +@export var multimeshes: Array[MultiMesh] = [] +@export var multimesh_transforms: Array[Transform3D] = [] diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index f7157af..b3bf747 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -16,12 +16,29 @@ ambient_light_energy = 0.5 script = ExtResource("1_stream") camera_path = NodePath("Camera3D") update_interval = 0.1 -max_concurrent_tile_tasks = 4 -chunk_ops_per_tick = 64 +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" cached_tile_mesh_limit = 48 -editor_follow_view_camera = false +wmo_render_cache_dir = "res://data/cache/wmo_render_v1" +enable_occlusion_culling = false enable_water = true -terrain_cast_shadows = true +enable_wmo = true +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) @@ -34,7 +51,6 @@ fast_mult = 8.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) light_energy = 1.5 -shadow_enabled = true directional_shadow_mode = 1 directional_shadow_fade_start = 0.85 directional_shadow_max_distance = 2200.0 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 38e3d58..ced32a9 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -4,6 +4,8 @@ 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 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 @@ -26,6 +28,24 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var chunk_ops_per_tick: int = 24 ## Heavy tile LOD creations per update tick @export var tile_lod_ops_per_tick: int = 1 +## Heavy tile LOD removals per update tick. +@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 +## 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. +@export var m2_build_groups_per_tick: int = 2 +## Maximum instances written into a single M2 MultiMesh per frame. +@export var m2_multimesh_batch_size: int = 128 +## Loaded M2 scene resources converted to meshes per frame. +@export var m2_mesh_finalize_ops_per_tick: int = 1 +## WMO instances attached per frame. +@export var wmo_build_instances_per_tick: int = 1 +## WMO render-cache mesh groups attached per frame. +@export var wmo_render_group_ops_per_tick: int = 24 +## Maximum cached WMO .tscn size instantiated while streaming. Larger WMOs need a lightweight render cache. +@export var wmo_max_runtime_scene_mb: float = 8.0 ## Minimum camera movement before recomputing streaming targets. @export var streaming_update_distance: float = CHUNK_SIZE * 2.0 ## Recently built tile meshes kept for quick revisits. @@ -34,8 +54,17 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var lod0_radius_chunks: int = 6 @export var lod1_radius_chunks: int = 12 @export var lod2_radius_chunks: int = 24 -@export var lod2_tile_radius: int = 8 +@export var lod2_tile_radius: int = 3 @export var warm_tile_margin: int = 1 +## Extra ADT ring loaded and GPU-warmed outside the visible terrain radius. +@export var prewarm_tile_margin: int = 1 +@export var prewarm_tile_lod_instances: bool = true +## Extra ADT rings retained after leaving the prewarm radius to avoid boundary churn. +@export var retain_tile_margin: int = 2 +## Pre-create hidden full terrain LOD this many ADT widths before it becomes visible. +@export var full_lod_prewarm_tiles: float = 1.0 +## Start loading the next ADT-centered ring before crossing a tile boundary. +@export_range(0.0, 0.49, 0.01) var boundary_prefetch_threshold: float = 0.35 @export var auto_position_camera: bool = true @export var editor_preview_enabled: bool = true @export var editor_preview_center_x: int = 32 @@ -43,9 +72,11 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var editor_preview_tile_radius: int = 1 @export var editor_follow_view_camera: bool = true @export var editor_persist_generated_nodes: bool = false +@export var enable_occlusion_culling: bool = true @export var use_rendering_server_for_terrain: bool = true @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 use_chunk_terrain_lods: bool = false @export var enable_water: bool = false @export var enable_wmo: bool = true @@ -53,19 +84,25 @@ const M2_RIGHT_YAW_OFFSET := 0.0 @export var enable_m2_placeholders: bool = false @export var m2_placeholder_only_trees: bool = true ## Dynamic doodad render radius in ADT tiles. Negative keeps all loaded M2s active. -@export var m2_tile_radius: int = 2 +@export var m2_tile_radius: int = 1 ## Dynamic WMO render radius in ADT tiles. Negative keeps all loaded WMOs active. -@export var wmo_tile_radius: int = 4 +@export var wmo_tile_radius: int = 3 ## Godot visibility range applied to instantiated M2 MultiMesh nodes. <= 0 disables it. -@export var m2_visibility_range: float = TILE_SIZE * 3.0 +@export var m2_visibility_range: float = 700.0 ## Godot visibility range applied to instantiated WMO trees. <= 0 disables it. -@export var wmo_visibility_range: float = TILE_SIZE * 5.0 +@export var wmo_visibility_range: float = 1800.0 +@export var wmo_render_cache_dir: String = "res://data/cache/wmo_render_v1" @export var m2_cache_dir: String = "res://data/cache/m2_glb" @export var wmo_cache_dir: String = "res://data/cache/wmo_tscn" @export var terrain_cast_shadows: bool = false @export var m2_cast_shadows: bool = false +@export var wmo_cast_shadows: bool = false @export var editor_reload_now: bool = false @export var debug_streaming: bool = false +@export var runtime_stats_enabled: bool = false +@export var runtime_stats_interval: float = 2.0 +@export var hitch_profiler_enabled: bool = false +@export var hitch_profiler_threshold_ms: float = 20.0 var _builder var _map_dir := "" @@ -78,6 +115,18 @@ var _chunk_remove_queue: Array = [] var _chunk_create_queue: Array = [] var _tile_lod_remove_queue: Array = [] var _tile_lod_create_queue: Array = [] +var _detail_asset_queue: Array = [] +var _detail_asset_queued: Dictionary = {} +var _m2_group_tasks: Dictionary = {} +var _m2_group_result_mutex := Mutex.new() +var _m2_group_result_queue: Array = [] +var _m2_build_jobs: Dictionary = {} +var _m2_build_queue: Array = [] +var _m2_mesh_cache: Dictionary = {} +var _m2_mesh_load_requests: Dictionary = {} +var _m2_mesh_finalize_queue: Array = [] +var _wmo_build_jobs: Dictionary = {} +var _wmo_build_queue: Array = [] var _tile_loading_tasks: Dictionary = {} var _tile_result_mutex := Mutex.new() var _tile_result_queue: Array = [] @@ -92,12 +141,19 @@ var _last_focus_pos := Vector3.ZERO var _dbg_chunks_created := 0 var _dbg_chunks_null := 0 var _dbg_last_report := 0.0 +var _runtime_stats_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 _wmo_prototype_cache: Dictionary = {} +var _wmo_render_cache: Dictionary = {} +var _wmo_render_load_requests: Dictionary = {} +var _wmo_render_build_jobs: Dictionary = {} +var _wmo_render_build_queue: Array = [] +var _wmo_scene_resource_cache: Dictionary = {} +var _wmo_scene_load_requests: Dictionary = {} var _wmo_missing_cache: Dictionary = {} var _m2_scene_cache: Dictionary = {} var _m2_missing_cache: Dictionary = {} @@ -110,6 +166,9 @@ func _ready() -> void: push_error("ADTLoader not found. Rebuild GDExtension first.") return + if enable_occlusion_culling: + get_viewport().set_use_occlusion_culling(true) + _builder = ADT_BUILDER_SCRIPT.new() _map_dir = extracted_dir.path_join("World/Maps/%s" % map_name) _abs_map_dir = ProjectSettings.globalize_path(_map_dir) @@ -149,12 +208,42 @@ func _process(delta: float) -> void: if _available_tiles.is_empty(): return + var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint() + var profile_start := Time.get_ticks_usec() if profile_enabled else 0 + var section_start := profile_start + var timings: Array[String] = [] + # Queues are always drained — works in both editor and game. _drain_tile_load_results() + _profile_section(timings, "finalize", 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 + _drain_m2_group_results() + _profile_section(timings, "m2results", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _drain_m2_mesh_loads() + _profile_section(timings, "m2mesh", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _process_m2_build_jobs() + _profile_section(timings, "m2build", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _process_wmo_build_jobs() + _profile_section(timings, "wmobuild", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _process_wmo_render_build_jobs() + _profile_section(timings, "wmogroups", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _process_detail_asset_queue() + _profile_section(timings, "details", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 + _tick_runtime_stats(delta) _update_accum += delta + var did_refresh := false if _update_accum < update_interval: + _log_hitch_profile(profile_start, timings, did_refresh, profile_enabled) return _update_accum = 0.0 @@ -162,6 +251,9 @@ func _process(delta: float) -> void: _tick_editor_streaming() else: _refresh_streaming_targets(false) + did_refresh = true + _profile_section(timings, "refresh", section_start, profile_enabled) + _log_hitch_profile(profile_start, timings, did_refresh, profile_enabled) func _exit_tree() -> void: @@ -308,7 +400,7 @@ func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> vo if _available_tiles.has(key): wanted_tiles[key] = true - _apply_streaming_target(wanted_tiles, focus_pos) + _apply_streaming_target(wanted_tiles, wanted_tiles, focus_pos) _note_refresh_focus(focus_pos) if force or debug_streaming: @@ -323,18 +415,27 @@ func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void: var focus_tile := _world_to_tile(focus_pos) # Chunk-LOD radius → tile radius (ceiling), plus margin for pre-warming. - var chunk_tile_radius: int = int(ceil(float(lod2_radius_chunks) / 16.0)) + warm_tile_margin - # Coarse tile radius covers everything beyond lod2 at the cheapest LOD. - var total_tile_radius: int = maxi(chunk_tile_radius, lod2_tile_radius) + var focus_tiles := _predictive_focus_tiles(focus_pos) + var total_tile_radius: int = _runtime_load_tile_radius() + var retain_tile_radius: int = _runtime_retain_tile_radius() + + var retained_tiles: Dictionary = {} + for center in focus_tiles: + for ty in range(center.y - retain_tile_radius, center.y + retain_tile_radius + 1): + for tx in range(center.x - retain_tile_radius, center.x + retain_tile_radius + 1): + var retained_key := _tile_key(tx, ty) + if _available_tiles.has(retained_key): + retained_tiles[retained_key] = true var wanted_tiles: Dictionary = {} - for ty in range(focus_tile.y - total_tile_radius, focus_tile.y + total_tile_radius + 1): - for tx in range(focus_tile.x - total_tile_radius, focus_tile.x + total_tile_radius + 1): - var key := _tile_key(tx, ty) - if _available_tiles.has(key): - wanted_tiles[key] = true + for center in focus_tiles: + for ty in range(center.y - total_tile_radius, center.y + total_tile_radius + 1): + for tx in range(center.x - total_tile_radius, center.x + total_tile_radius + 1): + var key := _tile_key(tx, ty) + if _available_tiles.has(key): + wanted_tiles[key] = true - _apply_streaming_target(wanted_tiles, focus_pos) + _apply_streaming_target(wanted_tiles, retained_tiles, focus_pos) _note_refresh_focus(focus_pos) if force or debug_streaming: @@ -344,7 +445,7 @@ func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void: -func _apply_streaming_target(wanted_tiles: Dictionary, focus_pos: Vector3) -> void: +func _apply_streaming_target(wanted_tiles: Dictionary, retained_tiles: Dictionary, focus_pos: Vector3) -> void: _last_focus_pos = focus_pos var filtered_load_queue: Array = [] @@ -377,13 +478,22 @@ func _apply_streaming_target(wanted_tiles: Dictionary, focus_pos: Vector3) -> vo for key in _tile_states.keys(): var state: Dictionary = _tile_states[key] var wanted := wanted_tiles.has(key) + var retained := retained_tiles.has(key) state["wanted"] = wanted + state["retained"] = retained if wanted: state["desired_lods"] = _compute_desired_lods(state, focus_pos) state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, focus_pos) + state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos) + elif retained: + state["desired_lods"] = {} + var current_tile_lod: int = int(state.get("tile_lod", -1)) + state["desired_tile_lod"] = current_tile_lod if current_tile_lod >= 0 else -1 + state["desired_tile_lod_visible"] = false else: state["desired_lods"] = {} state["desired_tile_lod"] = -1 + state["desired_tile_lod_visible"] = false state = _sync_detail_assets(state, focus_pos) _tile_states[key] = state @@ -411,15 +521,26 @@ func _rebuild_chunk_queues() -> void: var desired_lods: Dictionary = state["desired_lods"] var current_tile_lod: int = int(state.get("tile_lod", -1)) var desired_tile_lod: int = int(state.get("desired_tile_lod", -1)) + var current_tile_lod_visible: bool = bool(state.get("tile_lod_visible", false)) + var desired_tile_lod_visible: bool = bool(state.get("desired_tile_lod_visible", false)) if desired_tile_lod >= 0: for chunk_id in current_chunks.keys(): _chunk_remove_queue.append({"tile": key, "chunk": chunk_id}) - if current_tile_lod != desired_tile_lod: + if current_tile_lod != desired_tile_lod or current_tile_lod_visible != desired_tile_lod_visible: _tile_lod_create_queue.append({ "tile": key, "lod": desired_tile_lod, + "visible": desired_tile_lod_visible, + "dist_sq": _tile_dist_sq(state, _last_focus_pos), + }) + elif _should_prepare_full_tile_lod(state, _last_focus_pos): + _tile_lod_create_queue.append({ + "tile": key, + "lod": 0, + "visible": false, + "prepare": true, "dist_sq": _tile_dist_sq(state, _last_focus_pos), }) continue @@ -465,15 +586,16 @@ func _rebuild_chunk_queues() -> void: func _process_queues() -> void: var ops_left: int = chunk_ops_per_tick var tile_lod_create_left: int = tile_lod_ops_per_tick + var tile_lod_remove_left: int = tile_lod_remove_ops_per_tick # Free stale terrain first so GPU descriptors/memory are reclaimed before new creates. while ops_left > 0 and not _chunk_remove_queue.is_empty(): _remove_chunk(_chunk_remove_queue.pop_back()) ops_left -= 1 - while ops_left > 0 and not _tile_lod_remove_queue.is_empty(): + while tile_lod_remove_left > 0 and not _tile_lod_remove_queue.is_empty(): _remove_tile_lod(_tile_lod_remove_queue.pop_back()) - ops_left -= 1 + tile_lod_remove_left -= 1 # Parse N ADT files this tick — the main throttle knob var loads_left: int = tiles_per_tick @@ -500,11 +622,166 @@ func _process_queues() -> void: _dbg_chunks_created, _dbg_chunks_null, _tile_states.size()]) +func _profile_section(timings: Array[String], label: String, start_usec: int, enabled: bool) -> void: + if not enabled: + return + var elapsed_ms := float(Time.get_ticks_usec() - start_usec) / 1000.0 + if elapsed_ms >= 0.05: + timings.append("%s=%.2f" % [label, elapsed_ms]) + + +func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bool, enabled: bool) -> void: + if not enabled: + return + 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" % [ + total_ms, + str(did_refresh), + _tile_load_queue.size(), + _tile_loading_tasks.size(), + _detail_asset_queue.size(), + _m2_group_tasks.size(), + _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), + _m2_build_queue.size(), + _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), + _wmo_render_build_queue.size(), + _tile_lod_create_queue.size(), + _tile_lod_remove_queue.size(), + _chunk_create_queue.size(), + _chunk_remove_queue.size(), + " ".join(timings), + ]) + + +func _tick_runtime_stats(delta: float) -> void: + if not runtime_stats_enabled or Engine.is_editor_hint(): + return + + _runtime_stats_accum += delta + if _runtime_stats_accum < maxf(runtime_stats_interval, 0.25): + return + _runtime_stats_accum = 0.0 + + var wanted_tiles := 0 + var retained_tiles := 0 + var chunk_nodes := 0 + var chunk_rids := 0 + var tile_lod_nodes := 0 + var tile_lod_rids := 0 + var tile_lod_hidden := 0 + var tile_lod_prepared := 0 + var m2_tiles := 0 + var m2_multimeshes := 0 + var m2_instances := 0 + + for state_variant in _tile_states.values(): + if not (state_variant is Dictionary): + continue + var state: Dictionary = state_variant + if bool(state.get("wanted", false)): + wanted_tiles += 1 + if bool(state.get("retained", false)): + retained_tiles += 1 + + var chunks: Dictionary = state.get("chunks", {}) + for entry_variant in chunks.values(): + if not (entry_variant is Dictionary): + continue + var entry: Dictionary = entry_variant + if bool(entry.get("empty", false)): + continue + var chunk_rid: RID = entry.get("rid", RID()) + if chunk_rid.is_valid(): + chunk_rids += 1 + elif entry.get("node", null) != null: + chunk_nodes += 1 + + var tile_lod_rid: RID = state.get("tile_lod_rid", RID()) + if tile_lod_rid.is_valid(): + tile_lod_rids += 1 + elif state.get("tile_lod_node", null) != null: + tile_lod_nodes += 1 + if int(state.get("tile_lod", -1)) >= 0 and not bool(state.get("tile_lod_visible", true)): + tile_lod_hidden += 1 + 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 root: Node = state.get("root", null) + if root == null or not is_instance_valid(root): + continue + var m2_root := root.get_node_or_null("M2s") + if m2_root == null: + continue + m2_tiles += 1 + var m2_counts := {"multimeshes": 0, "instances": 0} + _accumulate_multimesh_stats(m2_root, m2_counts) + 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" % [ + Engine.get_frames_per_second(), + _tile_states.size(), + wanted_tiles, + retained_tiles, + _tile_loading_tasks.size(), + _tile_mesh_cache.size(), + tile_lod_rids, + tile_lod_nodes, + tile_lod_hidden, + tile_lod_prepared, + chunk_rids, + chunk_nodes, + _tile_load_queue.size(), + _detail_asset_queue.size(), + _m2_group_tasks.size(), + _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), + _m2_build_queue.size(), + _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), + _wmo_render_build_queue.size(), + _tile_lod_create_queue.size(), + _tile_lod_remove_queue.size(), + _chunk_create_queue.size(), + _chunk_remove_queue.size(), + m2_tiles, + m2_multimeshes, + m2_instances, + _wmo_registry.size(), + ]) + + +func _accumulate_multimesh_stats(node: Node, counts: Dictionary) -> void: + if node is MultiMeshInstance3D: + var mmi := node as MultiMeshInstance3D + if mmi.multimesh: + counts["multimeshes"] = int(counts["multimeshes"]) + 1 + counts["instances"] = int(counts["instances"]) + mmi.multimesh.instance_count + for child in node.get_children(): + _accumulate_multimesh_stats(child, counts) + + func _start_tile_load_async(request: Dictionary) -> void: var key: String = request["key"] if _tile_loading_tasks.has(key): return + var stream_res_path := _get_streaming_tile_resource_path(int(request["tx"]), int(request["ty"])) + if _can_use_baked_tile_cache() and ResourceLoader.exists(stream_res_path): + var err := ResourceLoader.load_threaded_request( + stream_res_path, + "", + false, + ResourceLoader.CACHE_MODE_REUSE) + if err == OK: + _tile_loading_tasks[key] = { + "mode": "stream", + "path": stream_res_path, + "request": request, + } + return + var baked_res_path := _get_baked_tile_resource_path(int(request["tx"]), int(request["ty"])) if _can_use_baked_tile_cache() and ResourceLoader.exists(baked_res_path): var err := ResourceLoader.load_threaded_request( @@ -552,10 +829,11 @@ func _load_tile_task(request: Dictionary) -> void: func _drain_tile_load_results() -> void: var results: Array = [] + var finalize_budget: int = maxi(1, tile_finalize_ops_per_tick) _tile_result_mutex.lock() - if not _tile_result_queue.is_empty(): - results = _tile_result_queue - _tile_result_queue = [] + while finalize_budget > 0 and not _tile_result_queue.is_empty(): + results.append(_tile_result_queue.pop_front()) + finalize_budget -= 1 _tile_result_mutex.unlock() for result in results: @@ -579,9 +857,13 @@ func _drain_tile_load_results() -> void: _finalize_loaded_tile(request, result["data"]) var baked_ready: Array = [] + if finalize_budget <= 0: + return for key in _tile_loading_tasks.keys(): + if baked_ready.size() >= finalize_budget: + break var pending: Dictionary = _tile_loading_tasks[key] - if pending.get("mode", "raw") != "baked": + if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream": continue var status := ResourceLoader.load_threaded_get_status(pending["path"]) @@ -603,27 +885,78 @@ func _drain_tile_load_results() -> void: _start_raw_tile_load_async(request) continue - var baked_tile: Resource = ResourceLoader.load_threaded_get(pending["path"]) + var loaded_tile: Resource = ResourceLoader.load_threaded_get(pending["path"]) if not _should_accept_loaded_tile(request): continue - if baked_tile == null or baked_tile.get_script() != BAKED_TILE_SCRIPT: + if pending.get("mode", "raw") == "stream": + if loaded_tile == null or loaded_tile.get_script() != STREAMING_TILE_SCRIPT: + push_warning("Invalid streaming tile resource: %s" % pending["path"]) + _start_raw_tile_load_async(request) + continue + _finalize_loaded_streaming_tile(request, loaded_tile) + continue + if loaded_tile == null or loaded_tile.get_script() != BAKED_TILE_SCRIPT: push_warning("Invalid baked tile resource: %s" % pending["path"]) _start_raw_tile_load_async(request) continue - _finalize_loaded_baked_tile(request, baked_tile) + _finalize_loaded_baked_tile(request, loaded_tile) func _wait_for_tile_tasks() -> void: for pending in _tile_loading_tasks.values(): if pending.get("mode", "raw") == "raw": WorkerThreadPool.wait_for_task_completion(int(pending["task_id"])) + elif pending.get("mode", "raw") == "baked" or pending.get("mode", "raw") == "stream": + var path: String = String(pending.get("path", "")) + if not path.is_empty(): + var status := ResourceLoader.load_threaded_get_status(path) + if status == ResourceLoader.THREAD_LOAD_LOADED: + ResourceLoader.load_threaded_get(path) _tile_loading_tasks.clear() + for task_id in _m2_group_tasks.values(): + WorkerThreadPool.wait_for_task_completion(int(task_id)) + _m2_group_tasks.clear() + _tile_result_mutex.lock() _tile_result_queue.clear() _tile_result_mutex.unlock() + _m2_group_result_mutex.lock() + _m2_group_result_queue.clear() + _m2_group_result_mutex.unlock() + + for pending in _m2_mesh_load_requests.values(): + var path: String = 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) + _m2_mesh_load_requests.clear() + _m2_mesh_finalize_queue.clear() + + for path_variant in _wmo_scene_load_requests.values(): + var path := String(path_variant) + if path.is_empty(): + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status == ResourceLoader.THREAD_LOAD_LOADED: + ResourceLoader.load_threaded_get(path) + _wmo_scene_load_requests.clear() + + for path_variant in _wmo_render_load_requests.values(): + var path := String(path_variant) + if path.is_empty(): + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status == ResourceLoader.THREAD_LOAD_LOADED: + ResourceLoader.load_threaded_get(path) + _wmo_render_load_requests.clear() + _wmo_render_build_jobs.clear() + _wmo_render_build_queue.clear() + func _should_accept_loaded_tile(request: Dictionary) -> bool: if _tile_states.has(request["key"]): @@ -639,8 +972,7 @@ func _is_tile_wanted_now(tx: int, ty: int) -> bool: if Engine.is_editor_hint(): radius = maxi(0, editor_preview_tile_radius) else: - var chunk_tile_radius: int = int(ceil(float(lod2_radius_chunks) / 16.0)) + warm_tile_margin - radius = maxi(chunk_tile_radius, lod2_tile_radius) + radius = _runtime_load_tile_radius() return abs(tx - focus_tile.x) <= radius and abs(ty - focus_tile.y) <= radius @@ -686,8 +1018,14 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void: "tile_lod_node": null, "tile_lod_rid": RID(), "tile_lod_mesh": null, + "tile_lod_visible": false, + "prepared_tile_lod": -1, + "prepared_tile_lod_rid": RID(), + "prepared_tile_lod_mesh": null, "desired_tile_lod": -1, + "desired_tile_lod_visible": false, "wanted": true, + "retained": true, "root": tile_root, "m2_names": data.get("m2_names", PackedStringArray()), "m2_placements": data.get("m2_placements", []), @@ -700,8 +1038,9 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void: # Use _last_focus_pos — already set correctly for both editor and game. state["desired_lods"] = _compute_desired_lods(state, _last_focus_pos) state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _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 state = _sync_detail_assets(state, _last_focus_pos) - _tile_states[key] = state _rebuild_chunk_queues() if debug_streaming: @@ -753,8 +1092,14 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v "tile_lod_node": null, "tile_lod_rid": RID(), "tile_lod_mesh": null, + "tile_lod_visible": false, + "prepared_tile_lod": -1, + "prepared_tile_lod_rid": RID(), + "prepared_tile_lod_mesh": null, "desired_tile_lod": -1, + "desired_tile_lod_visible": false, "wanted": true, + "retained": true, "root": tile_root, "m2_names": baked_m2_names, "m2_placements": baked_m2_placements, @@ -765,6 +1110,8 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v } 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 state = _sync_detail_assets(state, _last_focus_pos) _tile_states[key] = state _rebuild_chunk_queues() @@ -772,6 +1119,79 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v print("StreamingWorld: loaded baked tile %d,%d" % [request["tx"], request["ty"]]) +func _finalize_loaded_streaming_tile(request: Dictionary, stream_tile: Resource) -> void: + var key: String = request["key"] + if _tile_states.has(key): + return + + var stream_version: int = int(stream_tile.get("format_version")) + if stream_version < STREAMING_TILE_SCRIPT.FORMAT_VERSION: + push_warning( + "Outdated streaming tile cache for %s_%d_%d (format=%d, required=%d). Falling back to baked/raw ADT load." % [ + map_name, request["tx"], request["ty"], stream_version, STREAMING_TILE_SCRIPT.FORMAT_VERSION + ] + ) + _start_raw_tile_load_async(request) + return + + var tile_root := Node3D.new() + tile_root.name = "Tile_%d_%d" % [request["tx"], request["ty"]] + _terrain_root.add_child(tile_root) + _set_editor_owner_recursive(tile_root) + + var tile_origin: Vector3 = stream_tile.get("tile_origin") + tile_root.position = tile_origin + var stream_m2_names_value: Variant = stream_tile.get("m2_names") + var stream_m2_placements_value: Variant = stream_tile.get("m2_placements") + var stream_m2_names: PackedStringArray = stream_m2_names_value if stream_m2_names_value is PackedStringArray else PackedStringArray() + var stream_m2_placements: Array = stream_m2_placements_value if stream_m2_placements_value is Array else [] + var stream_wmo_names_value: Variant = stream_tile.get("wmo_names") + var stream_wmo_placements_value: Variant = stream_tile.get("wmo_placements") + var stream_wmo_names: PackedStringArray = stream_wmo_names_value if stream_wmo_names_value is PackedStringArray else PackedStringArray() + var stream_wmo_placements: Array = stream_wmo_placements_value if stream_wmo_placements_value is Array else [] + var state := { + "key": key, + "tx": request["tx"], + "ty": request["ty"], + "path": request["path"], + "origin": tile_origin, + "data": {}, + "baked_tile": null, + "stream_tile": stream_tile, + "tex_names": PackedStringArray(), + "chunks": {}, + "desired_lods": {}, + "tile_lod": -1, + "tile_lod_node": null, + "tile_lod_rid": RID(), + "tile_lod_mesh": null, + "tile_lod_visible": false, + "prepared_tile_lod": -1, + "prepared_tile_lod_rid": RID(), + "prepared_tile_lod_mesh": null, + "desired_tile_lod": -1, + "desired_tile_lod_visible": false, + "wanted": true, + "retained": true, + "root": tile_root, + "m2_names": stream_m2_names, + "m2_placements": stream_m2_placements, + "m2_built": false, + "wmo_names": stream_wmo_names, + "wmo_placements": stream_wmo_placements, + "wmo_refs": [], + } + + 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 + state = _sync_detail_assets(state, _last_focus_pos) + _tile_states[key] = state + _rebuild_chunk_queues() + if debug_streaming: + print("StreamingWorld: loaded streaming tile %d,%d" % [request["tx"], request["ty"]]) + + func _create_tile_lod(op: Dictionary) -> void: var key: String = op["tile"] if not _tile_states.has(key): @@ -779,37 +1199,70 @@ func _create_tile_lod(op: Dictionary) -> void: var state: Dictionary = _tile_states[key] var lod: int = int(op["lod"]) + var visible: bool = bool(op.get("visible", true)) + var prepare_only: bool = bool(op.get("prepare", false)) if int(state.get("tile_lod", -1)) == lod: + _set_tile_lod_visible(state, visible) + state["tile_lod_visible"] = visible + _tile_states[key] = state + return + + if prepare_only: + if int(state.get("prepared_tile_lod", -1)) == lod: + return + var prepared_mesh := _get_tile_lod_mesh_for_state(key, state, lod) + if prepared_mesh == null or not use_rendering_server_for_terrain: + return + var prepared_rid := _create_render_instance( + prepared_mesh, + _tile_local_to_world_transform(state, Vector3.ZERO)) + if not prepared_rid.is_valid(): + return + RenderingServer.instance_set_visible(prepared_rid, false) + var old_prepared_rid: RID = state.get("prepared_tile_lod_rid", RID()) + if old_prepared_rid.is_valid(): + _free_render_instance(old_prepared_rid) + state["prepared_tile_lod"] = lod + state["prepared_tile_lod_rid"] = prepared_rid + state["prepared_tile_lod_mesh"] = prepared_mesh + _tile_states[key] = state return var old_node: Node = state.get("tile_lod_node", null) var old_rid: RID = state.get("tile_lod_rid", RID()) + var prepared_lod: int = int(state.get("prepared_tile_lod", -1)) + var prepared_rid: RID = state.get("prepared_tile_lod_rid", RID()) + if prepared_lod == lod and prepared_rid.is_valid(): + state["tile_lod"] = lod + state["tile_lod_node"] = null + state["tile_lod_rid"] = prepared_rid + state["tile_lod_mesh"] = state.get("prepared_tile_lod_mesh", null) + state["prepared_tile_lod"] = -1 + state["prepared_tile_lod_rid"] = RID() + state["prepared_tile_lod_mesh"] = null + RenderingServer.instance_set_visible(prepared_rid, visible) + if old_rid.is_valid(): + _free_render_instance(old_rid) + if old_node: + old_node.queue_free() + state["tile_lod_visible"] = visible + _tile_states[key] = state + return + state["tile_lod"] = lod state["tile_lod_node"] = null state["tile_lod_rid"] = RID() state["tile_lod_mesh"] = null - var cache_key := _tile_mesh_cache_key(key, lod) - var mesh: Mesh = _get_cached_tile_mesh(cache_key) - if mesh == null: - mesh = _get_baked_tile_mesh(state, lod) - if mesh == null: - var payload: Dictionary = _builder.build_tile_coarse_render_payload( - state["data"], - _shared_tex_cache, - ProjectSettings.globalize_path(extracted_dir), - lod) - if not payload.is_empty(): - mesh = payload.get("mesh", null) - if mesh: - _store_cached_tile_mesh(cache_key, mesh) + var mesh := _get_tile_lod_mesh_for_state(key, state, lod) if use_rendering_server_for_terrain and mesh: var instance_rid := _create_render_instance( mesh, _tile_local_to_world_transform(state, Vector3.ZERO)) if instance_rid.is_valid(): + RenderingServer.instance_set_visible(instance_rid, visible) state["tile_lod_rid"] = instance_rid state["tile_lod_mesh"] = mesh @@ -817,6 +1270,7 @@ func _create_tile_lod(op: Dictionary) -> void: var node := MeshInstance3D.new() node.name = "TileLOD%d" % lod node.mesh = mesh + node.visible = visible node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if terrain_cast_shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF state["tile_lod_node"] = node state["tile_lod_mesh"] = mesh @@ -827,9 +1281,30 @@ func _create_tile_lod(op: Dictionary) -> void: _free_render_instance(old_rid) if old_node: old_node.queue_free() + state["tile_lod_visible"] = visible _tile_states[key] = state +func _get_tile_lod_mesh_for_state(key: String, state: Dictionary, lod: int) -> Mesh: + var cache_key := _tile_mesh_cache_key(key, lod) + var mesh: Mesh = _get_cached_tile_mesh(cache_key) + if mesh != null: + return mesh + + mesh = _get_baked_tile_mesh(state, lod) + if mesh == null: + var payload: Dictionary = _builder.build_tile_coarse_render_payload( + state["data"], + _shared_tex_cache, + ProjectSettings.globalize_path(extracted_dir), + lod) + if not payload.is_empty(): + mesh = payload.get("mesh", null) + if mesh: + _store_cached_tile_mesh(cache_key, mesh) + return mesh + + func _create_chunk(op: Dictionary) -> void: var key: String = op["tile"] if not _tile_states.has(key): @@ -937,7 +1412,7 @@ func _remove_chunk(op: Dictionary) -> void: current_chunks.erase(chunk_id) state["chunks"] = current_chunks - if not state["wanted"] and current_chunks.is_empty(): + if not bool(state.get("retained", false)) and current_chunks.is_empty(): _release_tile_if_unused(key) else: _tile_states[key] = state @@ -958,14 +1433,24 @@ func _remove_tile_lod(op: Dictionary) -> void: state["tile_lod_node"] = null state["tile_lod_rid"] = RID() state["tile_lod_mesh"] = null + state["tile_lod_visible"] = false state["tile_lod"] = -1 - if not state["wanted"] and state["chunks"].is_empty(): + if not bool(state.get("retained", false)) and state["chunks"].is_empty(): _release_tile_if_unused(key) else: _tile_states[key] = state +func _set_tile_lod_visible(state: Dictionary, visible: bool) -> void: + var instance_rid: RID = state.get("tile_lod_rid", RID()) + if instance_rid.is_valid(): + RenderingServer.instance_set_visible(instance_rid, visible) + var node: Node = state.get("tile_lod_node", null) + if node and is_instance_valid(node): + node.visible = visible + + func _release_tile(key: String) -> void: if not _tile_states.has(key): return @@ -975,6 +1460,9 @@ func _release_tile(key: String) -> void: var tile_lod_rid: RID = state.get("tile_lod_rid", RID()) if tile_lod_rid.is_valid(): _free_render_instance(tile_lod_rid) + var prepared_tile_lod_rid: RID = state.get("prepared_tile_lod_rid", RID()) + if prepared_tile_lod_rid.is_valid(): + _free_render_instance(prepared_tile_lod_rid) for entry in state.get("chunks", {}).values(): var chunk_rid: RID = entry.get("rid", RID()) if chunk_rid.is_valid(): @@ -991,6 +1479,19 @@ func _clear_streamed_world() -> void: _chunk_create_queue.clear() _tile_lod_remove_queue.clear() _tile_lod_create_queue.clear() + _detail_asset_queue.clear() + _detail_asset_queued.clear() + for key in _m2_build_jobs.keys(): + _cancel_m2_build_job(String(key)) + _m2_build_queue.clear() + _m2_mesh_load_requests.clear() + _m2_mesh_finalize_queue.clear() + _wmo_build_jobs.clear() + _wmo_build_queue.clear() + _wmo_scene_load_requests.clear() + _wmo_render_load_requests.clear() + _wmo_render_build_jobs.clear() + _wmo_render_build_queue.clear() _tile_result_mutex.lock() _tile_result_queue.clear() @@ -1001,6 +1502,9 @@ func _clear_streamed_world() -> void: var tile_lod_rid: RID = state.get("tile_lod_rid", RID()) if tile_lod_rid.is_valid(): _free_render_instance(tile_lod_rid) + var prepared_tile_lod_rid: RID = state.get("prepared_tile_lod_rid", RID()) + if prepared_tile_lod_rid.is_valid(): + _free_render_instance(prepared_tile_lod_rid) for entry in state.get("chunks", {}).values(): var chunk_rid: RID = entry.get("rid", RID()) if chunk_rid.is_valid(): @@ -1055,16 +1559,65 @@ func _compute_desired_lods(state: Dictionary, cam_pos: Vector3) -> Dictionary: return desired +func _runtime_visible_tile_radius() -> int: + var chunk_tile_radius: int = int(ceil(float(lod2_radius_chunks) / 16.0)) + warm_tile_margin + return maxi(chunk_tile_radius, lod2_tile_radius) + + +func _runtime_load_tile_radius() -> int: + return _runtime_visible_tile_radius() + maxi(0, prewarm_tile_margin) + + +func _runtime_retain_tile_radius() -> int: + return _runtime_load_tile_radius() + maxi(0, retain_tile_margin) + + +func _predictive_focus_tiles(focus_pos: Vector3) -> Array[Vector2i]: + var base := _world_to_tile(focus_pos) + var result: Array[Vector2i] = [base] + var threshold: float = clampf(boundary_prefetch_threshold, 0.0, 0.49) + if threshold <= 0.0: + return result + + var local_x := fposmod(focus_pos.x, TILE_SIZE) / TILE_SIZE + var local_z := fposmod(focus_pos.z, TILE_SIZE) / TILE_SIZE + var x_offsets: Array[int] = [0] + var z_offsets: Array[int] = [0] + + if local_x <= threshold: + x_offsets.append(-1) + elif local_x >= 1.0 - threshold: + x_offsets.append(1) + + if local_z <= threshold: + z_offsets.append(-1) + elif local_z >= 1.0 - threshold: + z_offsets.append(1) + + for ox in x_offsets: + for oz in z_offsets: + if ox == 0 and oz == 0: + continue + result.append(Vector2i(base.x + ox, base.y + oz)) + + return result + + func _compute_desired_tile_lod(state: Dictionary, cam_pos: Vector3) -> int: var tile_dist_sq: float = _tile_dist_sq(state, cam_pos) var chunk_radius_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2 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: + return 3 if tile_dist_sq <= prewarm_radius_sq else -1 if not use_chunk_terrain_lods: if tile_dist_sq <= chunk_radius_sq: return 0 if tile_dist_sq <= tile_radius_sq: return 3 + if prewarm_tile_lod_instances and tile_dist_sq <= prewarm_radius_sq: + return 3 return -1 if tile_dist_sq <= chunk_radius_sq: @@ -1074,6 +1627,29 @@ func _compute_desired_tile_lod(state: Dictionary, cam_pos: Vector3) -> int: return -1 +func _compute_desired_tile_lod_visible(state: Dictionary, cam_pos: Vector3) -> bool: + var tile_dist_sq: float = _tile_dist_sq(state, cam_pos) + var chunk_radius_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2 + var tile_radius_sq: float = (lod2_tile_radius * TILE_SIZE) ** 2 + return tile_dist_sq <= maxf(chunk_radius_sq, tile_radius_sq) + + +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 + if state.get("stream_tile", null) != null: + return false + if int(state.get("tile_lod", -1)) <= 0: + return false + if int(state.get("prepared_tile_lod", -1)) == 0: + return false + + var tile_dist_sq: float = _tile_dist_sq(state, cam_pos) + var chunk_radius: float = float(lod2_radius_chunks) * CHUNK_SIZE + var prepare_radius: float = chunk_radius + maxf(0.0, full_lod_prewarm_tiles) * TILE_SIZE + return tile_dist_sq <= prepare_radius * prepare_radius + + func _is_tile_chunk_set_ready(state: Dictionary) -> bool: var desired_lods: Dictionary = state.get("desired_lods", {}) var current_chunks: Dictionary = state.get("chunks", {}) @@ -1151,7 +1727,14 @@ func _get_baked_tile_resource_path(tx: int, ty: int) -> String: return baked_tile_cache_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty]) +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_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 baked_tile == null: return null @@ -1226,7 +1809,6 @@ func _place_global_wmo() -> void: return _world_wmo_root.add_child(instance) - _apply_visibility_range_recursive(instance, wmo_visibility_range) _set_editor_owner_recursive(instance) _wmo_registry["global"] = { "node": instance, @@ -1244,6 +1826,61 @@ func _sync_detail_assets(state: Dictionary, focus_pos: Vector3) -> Dictionary: return state if enable_wmo and _tile_within_detail_radius(state, focus_pos, wmo_tile_radius): + var refs: Array = state.get("wmo_refs", []) + var wmo_placements: Array = state.get("wmo_placements", []) + var wmo_key := String(state.get("key", "")) + if refs.is_empty() and not wmo_placements.is_empty() and not _wmo_build_jobs.has(wmo_key): + _enqueue_detail_asset_sync(String(state.get("key", ""))) + else: + _unregister_tile_wmos(state) + + if enable_m2_assets and _tile_within_detail_radius(state, focus_pos, m2_tile_radius): + var key := String(state.get("key", "")) + var m2_placements: Array = state.get("m2_placements", []) + if ( + not bool(state.get("m2_built", false)) + and not m2_placements.is_empty() + and not _m2_group_tasks.has(key) + and not _m2_build_jobs.has(key) + ): + _enqueue_detail_asset_sync(key) + else: + _remove_tile_m2_assets(state) + state["m2_built"] = false + + return state + + +func _enqueue_detail_asset_sync(key: String) -> void: + if key.is_empty() or _detail_asset_queued.has(key): + return + _detail_asset_queued[key] = true + _detail_asset_queue.append(key) + + +func _process_detail_asset_queue() -> void: + var ops_left: int = maxi(0, detail_asset_ops_per_tick) + while ops_left > 0 and not _detail_asset_queue.is_empty(): + var key: String = String(_detail_asset_queue.pop_front()) + _detail_asset_queued.erase(key) + _process_detail_asset_key(key) + ops_left -= 1 + + +func _process_detail_asset_key(key: String) -> void: + if not _tile_states.has(key): + return + var state: Dictionary = _tile_states[key] + if not bool(state.get("wanted", false)): + _disable_tile_detail_assets(state) + _tile_states[key] = state + return + + var tile_root: Node = state.get("root", null) + if tile_root == null or not is_instance_valid(tile_root): + return + + if enable_wmo and _tile_within_detail_radius(state, _last_focus_pos, wmo_tile_radius): var refs: Array = state.get("wmo_refs", []) if refs.is_empty(): _register_tile_wmos( @@ -1253,10 +1890,11 @@ func _sync_detail_assets(state: Dictionary, focus_pos: Vector3) -> Dictionary: else: _unregister_tile_wmos(state) - if enable_m2_assets and _tile_within_detail_radius(state, focus_pos, m2_tile_radius): + if enable_m2_assets and _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius): if not bool(state.get("m2_built", false)): - state["m2_built"] = _build_tile_m2_assets( + state["m2_built"] = _request_tile_m2_assets( tile_root as Node3D, + String(state.get("key", "")), state.get("origin", Vector3.ZERO), state.get("m2_names", PackedStringArray()), state.get("m2_placements", [])) @@ -1264,7 +1902,7 @@ func _sync_detail_assets(state: Dictionary, focus_pos: Vector3) -> Dictionary: _remove_tile_m2_assets(state) state["m2_built"] = false - return state + _tile_states[key] = state func _disable_tile_detail_assets(state: Dictionary) -> void: @@ -1288,47 +1926,385 @@ func _register_tile_wmos(state: Dictionary, wmo_names: PackedStringArray, wmo_pl _ensure_world_wmo_root() var tile_key: String = state["key"] + if _wmo_build_jobs.has(tile_key): + state["wmo_building"] = true + return - for i in wmo_placements.size(): - var placement_variant = wmo_placements[i] - if not (placement_variant is Dictionary): + _wmo_build_jobs[tile_key] = { + "names": wmo_names, + "placements": wmo_placements, + "index": 0, + "refs": refs, + } + _wmo_build_queue.append(tile_key) + state["wmo_building"] = true + + +func _process_wmo_build_jobs() -> void: + _drain_wmo_render_loads() + _drain_wmo_scene_loads() + var ops_left: int = maxi(0, wmo_build_instances_per_tick) + while ops_left > 0 and not _wmo_build_queue.is_empty(): + var tile_key: String = String(_wmo_build_queue.front()) + if not _wmo_build_jobs.has(tile_key): + _wmo_build_queue.pop_front() continue - var placement: Dictionary = placement_variant - var name_id: int = int(placement.get("name_id", -1)) - if name_id < 0 or name_id >= wmo_names.size(): + if not _tile_states.has(tile_key): + _wmo_build_jobs.erase(tile_key) + _wmo_build_queue.pop_front() continue - var unique_key := _wmo_unique_key(placement, tile_key, i) - - if _wmo_registry.has(unique_key): - var entry: Dictionary = _wmo_registry[unique_key] - var entry_refs: Dictionary = entry["refs"] - entry_refs[tile_key] = true - refs.append(unique_key) + var state: Dictionary = _tile_states[tile_key] + if not bool(state.get("wanted", false)) or not _tile_within_detail_radius(state, _last_focus_pos, wmo_tile_radius): + _cancel_wmo_build_job(tile_key) + _unregister_tile_wmos(state) + _tile_states[tile_key] = state continue - var rel_path: String = str(wmo_names[name_id]).replace("\\", "/") - var instance := _instantiate_wmo_world(rel_path, placement) - if instance == null: + var job: Dictionary = _wmo_build_jobs[tile_key] + var wmo_names: PackedStringArray = job.get("names", PackedStringArray()) + var wmo_placements: Array = job.get("placements", []) + var refs: Array = job.get("refs", []) + var index: int = int(job.get("index", 0)) + if index >= wmo_placements.size(): + state["wmo_refs"] = refs + state["wmo_building"] = false + _tile_states[tile_key] = state + _wmo_build_jobs.erase(tile_key) + _wmo_build_queue.pop_front() continue - _world_wmo_root.add_child(instance) - _apply_visibility_range_recursive(instance, wmo_visibility_range) - _set_editor_owner_recursive(instance) - _wmo_registry[unique_key] = { - "node": instance, - "refs": {tile_key: true}, - } - refs.append(unique_key) + var placement_variant = wmo_placements[index] + var advance_job := true + if placement_variant is Dictionary: + var placement: Dictionary = placement_variant + var name_id: int = int(placement.get("name_id", -1)) + if name_id >= 0 and name_id < wmo_names.size(): + var unique_key := _wmo_unique_key(placement, tile_key, index) + if _wmo_registry.has(unique_key): + var entry: Dictionary = _wmo_registry[unique_key] + var entry_refs: Dictionary = entry["refs"] + entry_refs[tile_key] = true + refs.append(unique_key) + else: + var rel_path: String = str(wmo_names[name_id]).replace("\\", "/") + var render_resource := _get_wmo_render_or_request(rel_path) + if render_resource != null: + var instance := _instantiate_wmo_render_root(rel_path, placement) + if instance != null: + _world_wmo_root.add_child(instance) + _set_editor_owner_recursive(instance) + _wmo_registry[unique_key] = { + "node": instance, + "refs": {tile_key: true}, + } + refs.append(unique_key) + _start_wmo_render_build(unique_key, instance, render_resource) + else: + var normalized_rel := _normalize_wmo_rel_path(rel_path) + if _wmo_render_load_requests.has(normalized_rel): + advance_job = false + else: + var scene := _get_wmo_scene_or_request(rel_path) + if scene == null: + if not _wmo_missing_cache.has(normalized_rel): + advance_job = false + else: + var instance := _instantiate_wmo_scene(rel_path, scene, placement) + if instance != null: + _prepare_runtime_wmo_instance(instance) + _world_wmo_root.add_child(instance) + _set_editor_owner_recursive(instance) + _wmo_registry[unique_key] = { + "node": instance, + "refs": {tile_key: true}, + } + refs.append(unique_key) + if advance_job: + job["refs"] = refs - state["wmo_refs"] = refs + if not advance_job: + _wmo_build_jobs[tile_key] = job + state["wmo_refs"] = refs + state["wmo_building"] = true + _tile_states[tile_key] = state + _wmo_build_queue.pop_front() + _wmo_build_queue.append(tile_key) + ops_left -= 1 + continue + + job["index"] = index + 1 + _wmo_build_jobs[tile_key] = job + state["wmo_refs"] = refs + state["wmo_building"] = true + _tile_states[tile_key] = state + ops_left -= 1 + + if int(job["index"]) >= wmo_placements.size(): + state["wmo_refs"] = refs + state["wmo_building"] = false + _tile_states[tile_key] = state + _wmo_build_jobs.erase(tile_key) + _wmo_build_queue.pop_front() + + +func _drain_wmo_scene_loads() -> void: + for normalized_rel_variant in _wmo_scene_load_requests.keys(): + var normalized_rel := String(normalized_rel_variant) + var path := String(_wmo_scene_load_requests[normalized_rel]) + var status := ResourceLoader.load_threaded_get_status(path) + if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED: + continue + _wmo_scene_load_requests.erase(normalized_rel) + if status != ResourceLoader.THREAD_LOAD_LOADED: + _wmo_missing_cache[normalized_rel] = true + continue + var resource: Resource = ResourceLoader.load_threaded_get(path) + if resource is PackedScene: + _wmo_scene_resource_cache[normalized_rel] = resource as PackedScene + else: + _wmo_missing_cache[normalized_rel] = true + + +func _drain_wmo_render_loads() -> void: + for normalized_rel_variant in _wmo_render_load_requests.keys(): + var normalized_rel := String(normalized_rel_variant) + var path := String(_wmo_render_load_requests[normalized_rel]) + var status := ResourceLoader.load_threaded_get_status(path) + if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED: + continue + _wmo_render_load_requests.erase(normalized_rel) + if status != ResourceLoader.THREAD_LOAD_LOADED: + continue + var resource: Resource = ResourceLoader.load_threaded_get(path) + if resource != null and resource.get_script() == WMO_STREAMING_SCRIPT: + _wmo_render_cache[normalized_rel] = resource + + +func _normalize_wmo_rel_path(rel_path: String) -> String: + return rel_path.replace("\\", "/").to_lower() + + +func _get_wmo_render_or_request(rel_path: String) -> Resource: + var normalized_rel := _normalize_wmo_rel_path(rel_path) + if normalized_rel.is_empty(): + return null + if _wmo_render_cache.has(normalized_rel): + return _wmo_render_cache[normalized_rel] + if _wmo_render_load_requests.has(normalized_rel): + return null + + var cache_path := wmo_render_cache_dir.path_join(normalized_rel.get_basename() + ".res") + if not ResourceLoader.exists(cache_path): + return null + + var err := ResourceLoader.load_threaded_request( + cache_path, + "", + false, + ResourceLoader.CACHE_MODE_REUSE) + if err == OK or err == ERR_BUSY: + _wmo_render_load_requests[normalized_rel] = cache_path + return null + + +func _get_wmo_scene_or_request(rel_path: String) -> PackedScene: + var normalized_rel := _normalize_wmo_rel_path(rel_path) + if normalized_rel.is_empty(): + return null + if _wmo_scene_resource_cache.has(normalized_rel): + return _wmo_scene_resource_cache[normalized_rel] + if _wmo_missing_cache.has(normalized_rel): + return null + if _wmo_scene_load_requests.has(normalized_rel): + return null + + var cache_path := wmo_cache_dir.path_join(normalized_rel.get_basename() + ".tscn") + if not ResourceLoader.exists(cache_path): + _wmo_missing_cache[normalized_rel] = true + return null + + if wmo_max_runtime_scene_mb > 0.0: + var size_bytes := _get_resource_file_size(cache_path) + var max_bytes := int(wmo_max_runtime_scene_mb * 1024.0 * 1024.0) + if size_bytes > max_bytes: + _wmo_missing_cache[normalized_rel] = true + if debug_streaming: + print("StreamingWorld: skipping large WMO %.2f MB: %s" % [ + float(size_bytes) / (1024.0 * 1024.0), + normalized_rel, + ]) + return null + + var err := ResourceLoader.load_threaded_request( + cache_path, + "", + false, + ResourceLoader.CACHE_MODE_REUSE) + if err == OK or err == ERR_BUSY: + _wmo_scene_load_requests[normalized_rel] = cache_path + else: + _wmo_missing_cache[normalized_rel] = true + return null + + +func _instantiate_wmo_render_root(rel_path: String, placement: Dictionary) -> Node3D: + var root := Node3D.new() + root.name = rel_path.get_file().get_basename() + root.position = placement.get("pos", Vector3.ZERO) + root.rotation = placement.get("rot", Vector3.ZERO) + var scale_value: float = float(placement.get("scale", 1.0)) + root.scale = Vector3.ONE * scale_value + return root + + +func _start_wmo_render_build(unique_key: String, root: Node3D, render_resource: Resource) -> void: + if unique_key.is_empty() or root == null or render_resource == null: + return + _wmo_render_build_jobs[unique_key] = { + "root": root, + "resource": render_resource, + "mesh_index": 0, + "multimesh_index": 0, + } + _wmo_render_build_queue.append(unique_key) + + +func _process_wmo_render_build_jobs() -> void: + var ops_left: int = maxi(0, wmo_render_group_ops_per_tick) + while ops_left > 0 and not _wmo_render_build_queue.is_empty(): + var unique_key := String(_wmo_render_build_queue.front()) + if not _wmo_render_build_jobs.has(unique_key): + _wmo_render_build_queue.pop_front() + continue + + var job: Dictionary = _wmo_render_build_jobs[unique_key] + var root: Node = job.get("root", null) + if root == null or not is_instance_valid(root): + _cancel_wmo_render_build_job(unique_key) + continue + + var render_resource: Resource = job.get("resource", null) + if render_resource == null: + _cancel_wmo_render_build_job(unique_key) + continue + + var meshes: Array = render_resource.get("meshes") + var mesh_transforms: Array = render_resource.get("mesh_transforms") + var mesh_names: PackedStringArray = render_resource.get("mesh_names") + var mesh_index: int = int(job.get("mesh_index", 0)) + if mesh_index < meshes.size(): + var mesh := meshes[mesh_index] as Mesh + if mesh != null: + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = mesh_names[mesh_index] if mesh_index < mesh_names.size() else "Group_%d" % mesh_index + mesh_instance.mesh = mesh + if mesh_index < mesh_transforms.size(): + mesh_instance.transform = mesh_transforms[mesh_index] + mesh_instance.cast_shadow = ( + GeometryInstance3D.SHADOW_CASTING_SETTING_ON + if wmo_cast_shadows + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + ) + if wmo_visibility_range > 0.0: + mesh_instance.visibility_range_end = wmo_visibility_range + mesh_instance.visibility_range_end_margin = CHUNK_SIZE + (root as Node3D).add_child(mesh_instance) + _set_editor_owner_recursive(mesh_instance) + job["mesh_index"] = mesh_index + 1 + _wmo_render_build_jobs[unique_key] = job + ops_left -= 1 + continue + + var multimeshes: Array = render_resource.get("multimeshes") + var multimesh_transforms: Array = render_resource.get("multimesh_transforms") + var multimesh_names: PackedStringArray = render_resource.get("multimesh_names") + var multimesh_index: int = int(job.get("multimesh_index", 0)) + if multimesh_index < multimeshes.size(): + var multimesh := multimeshes[multimesh_index] as MultiMesh + if multimesh != null: + var multimesh_instance := MultiMeshInstance3D.new() + multimesh_instance.name = ( + multimesh_names[multimesh_index] + if multimesh_index < multimesh_names.size() + else "DoodadGroup_%d" % multimesh_index + ) + multimesh_instance.multimesh = multimesh + if multimesh_index < multimesh_transforms.size(): + multimesh_instance.transform = multimesh_transforms[multimesh_index] + multimesh_instance.cast_shadow = ( + GeometryInstance3D.SHADOW_CASTING_SETTING_ON + if wmo_cast_shadows + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + ) + if wmo_visibility_range > 0.0: + multimesh_instance.visibility_range_end = wmo_visibility_range + multimesh_instance.visibility_range_end_margin = CHUNK_SIZE + (root as Node3D).add_child(multimesh_instance) + _set_editor_owner_recursive(multimesh_instance) + job["multimesh_index"] = multimesh_index + 1 + _wmo_render_build_jobs[unique_key] = job + ops_left -= 1 + continue + + _cancel_wmo_render_build_job(unique_key) + + +func _cancel_wmo_render_build_job(unique_key: String) -> void: + if _wmo_render_build_jobs.has(unique_key): + _wmo_render_build_jobs.erase(unique_key) + _wmo_render_build_queue.erase(unique_key) + + +func _get_resource_file_size(path: String) -> int: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + file = FileAccess.open(ProjectSettings.globalize_path(path), FileAccess.READ) + if file == null: + return 0 + var length := file.get_length() + file.close() + return int(length) + + +func _instantiate_wmo_scene(rel_path: String, scene: PackedScene, placement: Dictionary) -> Node3D: + if scene == null: + return null + var instance := scene.instantiate() as Node3D + if instance == null: + return null + instance.name = rel_path.get_file().get_basename() + instance.position = placement.get("pos", Vector3.ZERO) + instance.rotation = placement.get("rot", Vector3.ZERO) + var scale_value: float = float(placement.get("scale", 1.0)) + instance.scale = Vector3.ONE * scale_value + return instance + + +func _prepare_runtime_wmo_instance(instance: Node3D) -> void: + if not enable_occlusion_culling: + var occluders := instance.get_node_or_null("Occluders") + if occluders != null: + instance.remove_child(occluders) + occluders.queue_free() + if wmo_cast_shadows: + _apply_shadow_cast_recursive(instance, true) + + +func _cancel_wmo_build_job(tile_key: String) -> void: + if _wmo_build_jobs.has(tile_key): + _wmo_build_jobs.erase(tile_key) + _wmo_build_queue.erase(tile_key) func _unregister_tile_wmos(state: Dictionary) -> void: var refs: Array = state.get("wmo_refs", []) + var tile_key: String = String(state.get("key", "")) + if not tile_key.is_empty(): + _cancel_wmo_build_job(tile_key) if refs.is_empty(): + state["wmo_building"] = false return - var tile_key: String = state["key"] for unique_key in refs: if not _wmo_registry.has(unique_key): @@ -1337,12 +2313,14 @@ func _unregister_tile_wmos(state: Dictionary) -> void: var entry_refs: Dictionary = entry["refs"] entry_refs.erase(tile_key) if entry_refs.is_empty(): + _cancel_wmo_render_build_job(String(unique_key)) var node: Node = entry.get("node", null) if node: node.queue_free() _wmo_registry.erase(unique_key) state["wmo_refs"] = [] + state["wmo_building"] = false func _wmo_unique_key(placement: Dictionary, tile_key: String, idx: int) -> String: @@ -1356,6 +2334,8 @@ func _wmo_unique_key(placement: Dictionary, tile_key: String, idx: int) -> Strin func _clear_wmo_registry() -> void: + _wmo_render_build_jobs.clear() + _wmo_render_build_queue.clear() for entry_variant in _wmo_registry.values(): if not (entry_variant is Dictionary): continue @@ -1369,13 +2349,31 @@ func _clear_wmo_registry() -> void: _world_wmo_root = null -func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> bool: - if m2_names.is_empty() or m2_placements.is_empty(): +func _request_tile_m2_assets( + tile_root: Node3D, + key: String, + tile_origin: Vector3, + m2_names: PackedStringArray, + m2_placements: Array) -> bool: + if key.is_empty() or m2_names.is_empty() or m2_placements.is_empty(): return false var existing_m2_root := tile_root.get_node_or_null("M2s") if existing_m2_root != null: return not existing_m2_root.is_queued_for_deletion() + if _m2_group_tasks.has(key) or _m2_build_jobs.has(key): + return false + var task_id: int = WorkerThreadPool.add_task( + _group_tile_m2_task.bind(key, tile_origin, m2_names, m2_placements)) + _m2_group_tasks[key] = task_id + return false + + +func _group_tile_m2_task( + key: String, + tile_origin: Vector3, + m2_names: PackedStringArray, + m2_placements: Array) -> void: var groups: Dictionary = {} for placement_variant in m2_placements: if not (placement_variant is Dictionary): @@ -1385,8 +2383,7 @@ func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: Pa if name_id < 0 or name_id >= m2_names.size(): continue - var rel_path: String = str(m2_names[name_id]).replace("\\", "/") - var normalized := rel_path + var normalized: String = str(m2_names[name_id]).replace("\\", "/") if normalized.ends_with(".mdx") or normalized.ends_with(".mdl"): normalized = normalized.get_basename() + ".m2" if normalized.is_empty(): @@ -1402,48 +2399,232 @@ func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: Pa groups[normalized] = [] (groups[normalized] as Array).append(xform) - if groups.is_empty(): - return false + _m2_group_result_mutex.lock() + _m2_group_result_queue.append({ + "key": key, + "groups": groups, + }) + _m2_group_result_mutex.unlock() - var m2_root := Node3D.new() - m2_root.name = "M2s" - tile_root.add_child(m2_root) - _set_editor_owner_recursive(m2_root) - for rel_path in groups.keys(): - var transforms: Array = groups[rel_path] - var mesh: Mesh = _get_or_load_m2_mesh(rel_path) - if mesh == null: +func _drain_m2_group_results() -> void: + var results: Array = [] + _m2_group_result_mutex.lock() + if not _m2_group_result_queue.is_empty(): + results = _m2_group_result_queue + _m2_group_result_queue = [] + _m2_group_result_mutex.unlock() + + for result_variant in results: + if not (result_variant is Dictionary): + continue + var result: Dictionary = result_variant + var key: String = String(result.get("key", "")) + if _m2_group_tasks.has(key): + WorkerThreadPool.wait_for_task_completion(int(_m2_group_tasks[key])) + _m2_group_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_detail_radius(state, _last_focus_pos, m2_tile_radius): + continue + if bool(state.get("m2_built", false)) or _m2_build_jobs.has(key): continue - var mm := MultiMesh.new() - mm.transform_format = MultiMesh.TRANSFORM_3D - mm.mesh = mesh - mm.instance_count = transforms.size() - for i in transforms.size(): - mm.set_instance_transform(i, transforms[i]) + var groups: Dictionary = result.get("groups", {}) + if groups.is_empty(): + state["m2_built"] = true + _tile_states[key] = state + continue - var mmi := MultiMeshInstance3D.new() - mmi.name = rel_path.get_file().get_basename() - mmi.multimesh = mm - mmi.cast_shadow = ( - GeometryInstance3D.SHADOW_CASTING_SETTING_ON - if m2_cast_shadows - else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF - ) - if m2_visibility_range > 0.0: - mmi.visibility_range_end = m2_visibility_range - mmi.visibility_range_end_margin = CHUNK_SIZE - m2_root.add_child(mmi) - _set_editor_owner_recursive(mmi) + var tile_root: Node = state.get("root", null) + if tile_root == null or not is_instance_valid(tile_root): + continue + var m2_root := Node3D.new() + m2_root.name = "M2s" + (tile_root as Node3D).add_child(m2_root) + _set_editor_owner_recursive(m2_root) - if m2_root.get_child_count() == 0: - m2_root.queue_free() - return false - return true + _m2_build_jobs[key] = { + "root": m2_root, + "groups": groups, + "keys": groups.keys(), + "index": 0, + "offset": 0, + "serial": 0, + } + _m2_build_queue.append(key) + + +func _drain_m2_mesh_loads() -> void: + for normalized_rel_variant in _m2_mesh_load_requests.keys(): + var normalized_rel := String(normalized_rel_variant) + var pending: Dictionary = _m2_mesh_load_requests[normalized_rel] + var path: String = String(pending.get("path", "")) + if path.is_empty(): + _m2_mesh_load_requests.erase(normalized_rel) + _m2_missing_cache[normalized_rel] = true + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED: + continue + pending["status"] = status + _m2_mesh_load_requests.erase(normalized_rel) + _m2_mesh_finalize_queue.append(pending) + + var ops_left: int = maxi(0, m2_mesh_finalize_ops_per_tick) + while ops_left > 0 and not _m2_mesh_finalize_queue.is_empty(): + var pending: Dictionary = _m2_mesh_finalize_queue.pop_front() + var normalized_rel := String(pending.get("normalized", "")) + if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel): + ops_left -= 1 + continue + if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: + _m2_missing_cache[normalized_rel] = true + ops_left -= 1 + continue + + var path := String(pending.get("path", "")) + var resource: Resource = ResourceLoader.load_threaded_get(path) + var mesh := _extract_first_mesh_from_m2_resource(resource) + if mesh != null: + _m2_mesh_cache[normalized_rel] = mesh + else: + _m2_missing_cache[normalized_rel] = true + ops_left -= 1 + + +func _process_m2_build_jobs() -> void: + var ops_left: int = maxi(0, m2_build_groups_per_tick) + while ops_left > 0 and not _m2_build_queue.is_empty(): + var key: String = String(_m2_build_queue.front()) + if not _m2_build_jobs.has(key): + _m2_build_queue.pop_front() + continue + if not _tile_states.has(key): + _cancel_m2_build_job(key) + _m2_build_queue.pop_front() + continue + + var state: Dictionary = _tile_states[key] + if not bool(state.get("wanted", false)) or not _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius): + _cancel_m2_build_job(key) + _m2_build_queue.pop_front() + continue + + var job: Dictionary = _m2_build_jobs[key] + var root: Node = job.get("root", null) + if root == null or not is_instance_valid(root): + _m2_build_jobs.erase(key) + _m2_build_queue.pop_front() + continue + + var group_keys: Array = job.get("keys", []) + var index: int = int(job.get("index", 0)) + if index >= group_keys.size(): + _finish_m2_build_job(key) + _m2_build_queue.pop_front() + continue + + var rel_path: String = String(group_keys[index]) + var groups: Dictionary = job.get("groups", {}) + var transforms: Array = groups.get(rel_path, []) + var offset: int = int(job.get("offset", 0)) + var batch_size: int = maxi(1, m2_multimesh_batch_size) + var batch_count: int = mini(batch_size, maxi(0, transforms.size() - offset)) + var serial: int = int(job.get("serial", 0)) + var consumed := batch_count + if batch_count > 0: + var mesh := _get_m2_mesh_or_request(rel_path) + if mesh == null: + var normalized_rel := _normalize_m2_rel_path(rel_path) + if not _m2_missing_cache.has(normalized_rel): + _m2_build_queue.pop_front() + _m2_build_queue.append(key) + ops_left -= 1 + continue + else: + _materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial) + job["serial"] = serial + 1 + if consumed <= 0 or offset + consumed >= transforms.size(): + job["index"] = index + 1 + job["offset"] = 0 + else: + job["offset"] = offset + consumed + _m2_build_jobs[key] = job + ops_left -= 1 + + if int(job["index"]) >= group_keys.size(): + _finish_m2_build_job(key) + _m2_build_queue.pop_front() + + +func _materialize_m2_group_batch( + m2_root: Node3D, + rel_path: String, + mesh: Mesh, + transforms: Array, + start: int, + count: int, + serial: int) -> void: + if transforms.is_empty() or count <= 0 or mesh == null: + return + + var mm := MultiMesh.new() + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.mesh = mesh + mm.instance_count = count + for i in count: + mm.set_instance_transform(i, transforms[start + i]) + + var mmi := MultiMeshInstance3D.new() + mmi.name = "%s_%d" % [rel_path.get_file().get_basename(), serial] + mmi.multimesh = mm + mmi.cast_shadow = ( + GeometryInstance3D.SHADOW_CASTING_SETTING_ON + if m2_cast_shadows + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + ) + if m2_visibility_range > 0.0: + mmi.visibility_range_end = m2_visibility_range + mmi.visibility_range_end_margin = CHUNK_SIZE + m2_root.add_child(mmi) + _set_editor_owner_recursive(mmi) + + +func _finish_m2_build_job(key: String) -> void: + if not _m2_build_jobs.has(key): + return + var job: Dictionary = _m2_build_jobs[key] + var root: Node = job.get("root", null) + var built := false + if root != null and is_instance_valid(root): + built = root.get_child_count() > 0 + if not built: + root.queue_free() + if _tile_states.has(key): + var state: Dictionary = _tile_states[key] + state["m2_built"] = true + _tile_states[key] = state + _m2_build_jobs.erase(key) + + +func _cancel_m2_build_job(key: String) -> void: + if not _m2_build_jobs.has(key): + return + var job: Dictionary = _m2_build_jobs[key] + var root: Node = job.get("root", null) + if root != null and is_instance_valid(root): + root.queue_free() + _m2_build_jobs.erase(key) func _remove_tile_m2_assets(state: Dictionary) -> void: + var key := String(state.get("key", "")) + if not key.is_empty(): + _cancel_m2_build_job(key) var tile_root: Node = state.get("root", null) if tile_root == null or not is_instance_valid(tile_root): return @@ -1463,14 +2644,105 @@ func _apply_visibility_range_recursive(node: Node, range_end: float) -> void: _apply_visibility_range_recursive(child, range_end) +func _apply_shadow_cast_recursive(node: Node, cast_shadows: bool) -> void: + if node is GeometryInstance3D: + var geometry := node as GeometryInstance3D + geometry.cast_shadow = ( + GeometryInstance3D.SHADOW_CASTING_SETTING_ON + if cast_shadows + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + ) + for child in node.get_children(): + _apply_shadow_cast_recursive(child, cast_shadows) + + +func _strip_occluders_recursive(node: Node) -> void: + for child in node.get_children(): + if child is OccluderInstance3D: + node.remove_child(child) + child.free() + else: + _strip_occluders_recursive(child) + + +func _normalize_m2_rel_path(rel_path: String) -> String: + var normalized_rel := rel_path.replace("\\", "/") + if normalized_rel.ends_with(".mdx") or normalized_rel.ends_with(".mdl"): + normalized_rel = normalized_rel.get_basename() + ".m2" + return normalized_rel + + +func _get_m2_mesh_or_request(rel_path: String) -> Mesh: + var normalized_rel := _normalize_m2_rel_path(rel_path) + if normalized_rel.is_empty(): + return null + if _m2_mesh_cache.has(normalized_rel): + return _m2_mesh_cache[normalized_rel] + if _m2_missing_cache.has(normalized_rel): + return null + _request_m2_mesh_load(normalized_rel) + return null + + +func _request_m2_mesh_load(normalized_rel: String) -> void: + if normalized_rel.is_empty() or _m2_mesh_load_requests.has(normalized_rel): + return + + for cache_res_path in [ + _get_m2_cache_resource_path(normalized_rel, ".tscn"), + _get_m2_cache_resource_path(normalized_rel, ".glb")]: + if not ResourceLoader.exists(cache_res_path): + continue + var err := ResourceLoader.load_threaded_request( + cache_res_path, + "", + false, + ResourceLoader.CACHE_MODE_REUSE) + if err == OK or err == ERR_BUSY: + _m2_mesh_load_requests[normalized_rel] = { + "normalized": normalized_rel, + "path": cache_res_path, + } + return + break + + _m2_missing_cache[normalized_rel] = true + + +func _extract_first_mesh_from_m2_resource(resource: Resource) -> Mesh: + if resource is Mesh: + return resource as Mesh + if resource is PackedScene: + var node := (resource as PackedScene).instantiate() + if node == null: + return null + var mesh := _find_first_mesh_recursive(node) + node.free() + return mesh + return null + + +func _find_first_mesh_recursive(node: Node) -> Mesh: + if node is MeshInstance3D: + return (node as MeshInstance3D).mesh + for child in node.get_children(): + var mesh := _find_first_mesh_recursive(child) + if mesh != null: + return mesh + return null + + func _get_or_load_m2_mesh(rel_path: String) -> Mesh: + var normalized_rel := _normalize_m2_rel_path(rel_path) + if _m2_mesh_cache.has(normalized_rel): + return _m2_mesh_cache[normalized_rel] var prototype: Node3D = _get_or_load_m2_prototype(rel_path) if prototype == null: return null - for child in prototype.get_children(): - if child is MeshInstance3D: - return (child as MeshInstance3D).mesh - return null + var mesh := _find_first_mesh_recursive(prototype) + if mesh != null and not normalized_rel.is_empty(): + _m2_mesh_cache[normalized_rel] = mesh + return mesh func _build_tile_m2_placeholders(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> void: @@ -1588,13 +2860,18 @@ func _instantiate_wmo_world(rel_path: String, placement: Dictionary) -> Node3D: instance.rotation = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) instance.scale = Vector3.ONE * scale_value + if not enable_occlusion_culling: + var occluders := instance.get_node_or_null("Occluders") + if occluders != null: + instance.remove_child(occluders) + occluders.queue_free() + if wmo_cast_shadows: + _apply_shadow_cast_recursive(instance, true) return instance func _get_or_load_m2_prototype(rel_path: String) -> Node3D: - var normalized_rel := rel_path.replace("\\", "/") - if normalized_rel.ends_with(".mdx") or normalized_rel.ends_with(".mdl"): - normalized_rel = normalized_rel.get_basename() + ".m2" + var normalized_rel := _normalize_m2_rel_path(rel_path) if normalized_rel.is_empty(): return null if _m2_scene_cache.has(normalized_rel): @@ -1779,7 +3056,7 @@ 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|%s" % [ + 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" % [ extracted_dir, map_name, 1 if editor_preview_enabled else 0, @@ -1792,9 +3069,27 @@ func _make_editor_signature() -> String: lod2_radius_chunks, lod2_tile_radius, warm_tile_margin, + prewarm_tile_margin, + 1 if prewarm_tile_lod_instances else 0, + retain_tile_margin, 1 if enable_m2_assets else 0, 1 if enable_m2_placeholders else 0, + m2_multimesh_batch_size, + m2_mesh_finalize_ops_per_tick, + wmo_build_instances_per_tick, + wmo_render_group_ops_per_tick, + wmo_max_runtime_scene_mb, + m2_tile_radius, + wmo_tile_radius, + 1 if wmo_cast_shadows else 0, + m2_visibility_range, + wmo_visibility_range, + full_lod_prewarm_tiles, + boundary_prefetch_threshold, m2_cache_dir, + wmo_cache_dir, + wmo_render_cache_dir, + streaming_tile_cache_dir, ] @@ -1803,7 +3098,7 @@ func _release_tile_if_unused(key: String) -> void: return var state: Dictionary = _tile_states[key] - if state["wanted"]: + if bool(state.get("retained", false)): _tile_states[key] = state return if not state["chunks"].is_empty(): diff --git a/src/tools/bake_adt_terrain_cache.gd b/src/tools/bake_adt_terrain_cache.gd index 8fdc8c4..c7a6d39 100644 --- a/src/tools/bake_adt_terrain_cache.gd +++ b/src/tools/bake_adt_terrain_cache.gd @@ -2,7 +2,7 @@ extends SceneTree ## Usage: ## godot --headless --path --script res://src/tools/bake_adt_terrain_cache.gd -- \ -## --map Azeroth --jobs 4 --force +## --map Azeroth --jobs auto --force ## ## `--jobs N` starts N separate Godot worker processes. Process isolation keeps ## ResourceSaver/Image/loader state independent while using multiple CPU cores. @@ -37,7 +37,7 @@ func _initialize() -> void: _get_arg_value( args, "--full-texture-size", - str(legacy_texture_size if legacy_texture_size != null else 4196) + str(legacy_texture_size if legacy_texture_size != null else 2048) ).to_int() ) var coarse_texture_size := maxi( @@ -48,11 +48,12 @@ func _initialize() -> void: str(legacy_texture_size if legacy_texture_size != null else 512) ).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 jobs := maxi(1, _get_arg_value(args, "--jobs", "1").to_int()) 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: @@ -127,18 +128,16 @@ func _initialize() -> void: _progress_update(tx, ty, baked, skipped, failed) continue - var full_payload: Dictionary = _builder.build_baked_tile_render_payload( + var payloads: Dictionary = _builder.build_baked_tile_render_payload_pair( data, _image_cache, ProjectSettings.globalize_path(extracted_dir), 0, - full_texture_size) - var coarse_payload: Dictionary = _builder.build_baked_tile_render_payload( - data, - _image_cache, - ProjectSettings.globalize_path(extracted_dir), + full_texture_size, 3, coarse_texture_size) + var full_payload: Dictionary = payloads.get("full", {}) + var coarse_payload: Dictionary = payloads.get("coarse", {}) if full_payload.is_empty(): push_warning("Bake produced empty full mesh: %s" % source_res_path) @@ -396,6 +395,13 @@ func _get_optional_int_arg(args: PackedStringArray, name: String): 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 diff --git a/src/tools/bake_all.gd b/src/tools/bake_all.gd index cf1f50b..4cef7a3 100644 --- a/src/tools/bake_all.gd +++ b/src/tools/bake_all.gd @@ -114,10 +114,16 @@ func _bake_terrain(map_name: String, map_dir: String, extracted: String, if data.is_empty() or not data.has("chunks"): failed += 1; continue - var full_payload := builder.build_baked_tile_render_payload( - data, _image_cache, ProjectSettings.globalize_path(extracted), 0, full_tex) - var coarse_payload := builder.build_baked_tile_render_payload( - data, _image_cache, ProjectSettings.globalize_path(extracted), 3, coarse_tex) + var payloads: Dictionary = builder.build_baked_tile_render_payload_pair( + data, + _image_cache, + ProjectSettings.globalize_path(extracted), + 0, + full_tex, + 3, + coarse_tex) + var full_payload: Dictionary = payloads.get("full", {}) + var coarse_payload: Dictionary = payloads.get("coarse", {}) if full_payload.is_empty(): failed += 1; continue diff --git a/src/tools/build_adt_streaming_cache.gd b/src/tools/build_adt_streaming_cache.gd new file mode 100644 index 0000000..fab516a --- /dev/null +++ b/src/tools/build_adt_streaming_cache.gd @@ -0,0 +1,98 @@ +extends SceneTree + +## Converts the heavy baked ADT cache into a lightweight runtime streaming cache. +## +## Usage: +## godot --headless --path --script res://src/tools/build_adt_streaming_cache.gd -- \ +## --map Azeroth --input res://data/cache/baked_terrain_v2 --output res://data/cache/baked_terrain_stream_v1 --force + +const BAKED_TILE_SCRIPT := preload("res://src/resources/baked_adt_tile.gd") +const STREAMING_TILE_SCRIPT := preload("res://src/resources/streaming_adt_tile.gd") + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var map_name := _arg(args, "--map", "Azeroth") + var input_dir := _res(_arg(args, "--input", "res://data/cache/baked_terrain_v2")) + var output_dir := _res(_arg(args, "--output", "res://data/cache/baked_terrain_stream_v1")) + var force := args.has("--force") + + var in_map_dir := input_dir.path_join(map_name) + var out_map_dir := output_dir.path_join(map_name) + var in_map_abs := ProjectSettings.globalize_path(in_map_dir) + var out_map_abs := ProjectSettings.globalize_path(out_map_dir) + if not DirAccess.dir_exists_absolute(in_map_abs): + push_error("Input baked terrain cache not found: %s" % in_map_dir) + quit(1) + return + DirAccess.make_dir_recursive_absolute(out_map_abs) + + var dir := DirAccess.open(in_map_dir) + if dir == null: + push_error("Cannot open input cache dir: %s" % in_map_dir) + quit(1) + return + + var files := dir.get_files() + files.sort() + var converted := 0 + var skipped := 0 + var failed := 0 + var started_ms := Time.get_ticks_msec() + + for file_name in files: + if not file_name.ends_with(".res"): + continue + var in_path := in_map_dir.path_join(file_name) + var out_path := out_map_dir.path_join(file_name) + if not force and ResourceLoader.exists(out_path): + skipped += 1 + continue + + var baked: Resource = load(in_path) + if baked == null or baked.get_script() != BAKED_TILE_SCRIPT: + push_warning("Invalid baked tile: %s" % in_path) + failed += 1 + continue + + var stream_tile: Resource = STREAMING_TILE_SCRIPT.new() + stream_tile.set("format_version", STREAMING_TILE_SCRIPT.FORMAT_VERSION) + stream_tile.set("map_name", str(baked.get("map_name"))) + stream_tile.set("tile_x", int(baked.get("tile_x"))) + stream_tile.set("tile_y", int(baked.get("tile_y"))) + stream_tile.set("tile_origin", baked.get("tile_origin")) + stream_tile.set("texture_size", int(baked.get("coarse_texture_size"))) + stream_tile.set("wmo_names", baked.get("wmo_names")) + stream_tile.set("wmo_placements", baked.get("wmo_placements")) + stream_tile.set("m2_names", baked.get("m2_names")) + stream_tile.set("m2_placements", baked.get("m2_placements")) + stream_tile.set("terrain_mesh", baked.get("coarse_mesh")) + + var err := ResourceSaver.save(stream_tile, out_path) + if err != OK: + push_warning("Failed to save streaming tile %s (err=%d)" % [out_path, err]) + failed += 1 + continue + + converted += 1 + if converted % 25 == 0: + print("Converted %d tiles..." % converted) + + var elapsed_ms := Time.get_ticks_msec() - started_ms + print("Streaming cache finished. converted=%d skipped=%d failed=%d elapsed=%.2fs" % [ + converted, skipped, failed, float(elapsed_ms) / 1000.0 + ]) + quit(0 if failed == 0 else 2) + + +func _arg(args: PackedStringArray, name: String, default: String) -> String: + var idx := args.find(name) + if idx >= 0 and idx + 1 < args.size(): + return args[idx + 1] + return default + + +func _res(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return path + return "res://" + path.trim_prefix("./").trim_prefix("/") diff --git a/src/tools/build_adt_streaming_cache.gd.uid b/src/tools/build_adt_streaming_cache.gd.uid new file mode 100644 index 0000000..fb0d8b4 --- /dev/null +++ b/src/tools/build_adt_streaming_cache.gd.uid @@ -0,0 +1 @@ +uid://ctokf2tfwftl7 diff --git a/src/tools/build_wmo_streaming_cache.gd b/src/tools/build_wmo_streaming_cache.gd new file mode 100644 index 0000000..5078977 --- /dev/null +++ b/src/tools/build_wmo_streaming_cache.gd @@ -0,0 +1,182 @@ +extends SceneTree + +## Builds a lightweight WMO render cache for runtime streaming. +## +## Usage: +## godot --headless --path --script res://src/tools/build_wmo_streaming_cache.gd -- \ +## --map Azeroth --extracted res://data/extracted --output res://data/cache/wmo_render_v1 --force + +const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd") +const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd") + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var map_name := _arg(args, "--map", "Azeroth") + var extracted := _res(_arg(args, "--extracted", "res://data/extracted")) + var output_dir := _res(_arg(args, "--output", "res://data/cache/wmo_render_v1")) + var force := args.has("--force") + + if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("WMOLoader"): + push_error("GDExtension not loaded. Rebuild first.") + quit(1) + return + + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir)) + + var unique := _collect_unique_wmos(map_name, extracted) + if unique.is_empty(): + push_error("No WMO references found for map: %s" % map_name) + quit(1) + return + + var wmo_loader = ClassDB.instantiate("WMOLoader") + var baked := 0 + var skipped := 0 + var failed := 0 + var total := unique.size() + var index := 0 + var started_ms := Time.get_ticks_msec() + + for rel_path_variant in unique.keys(): + index += 1 + var rel_path := String(rel_path_variant) + var out_path := _get_wmo_render_path(output_dir, rel_path) + if not force and ResourceLoader.exists(out_path): + skipped += 1 + continue + + var abs_wmo := ProjectSettings.globalize_path(extracted.path_join(rel_path)) + if not FileAccess.file_exists(abs_wmo): + failed += 1 + continue + + var data: Dictionary = wmo_loader.call("load_wmo", abs_wmo) + if data.is_empty(): + failed += 1 + continue + + var node: Node3D = WMO_BUILDER_SCRIPT.build(data, extracted) + if node == null: + failed += 1 + continue + + var render_resource: Resource = WMO_STREAMING_SCRIPT.new() + render_resource.set("format_version", WMO_STREAMING_SCRIPT.FORMAT_VERSION) + render_resource.set("source_path", rel_path) + _collect_render_payload(node, render_resource) + node.free() + + if (render_resource.get("meshes") as Array).is_empty() and (render_resource.get("multimeshes") as Array).is_empty(): + failed += 1 + continue + + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out_path.get_base_dir())) + var err := ResourceSaver.save(render_resource, out_path) + if err != OK: + push_warning("save failed for %s: %d" % [out_path, err]) + failed += 1 + continue + + baked += 1 + if index % 10 == 0 or index == total: + print("[%d/%d] baked=%d skipped=%d failed=%d" % [index, total, baked, skipped, failed]) + + var elapsed_ms := Time.get_ticks_msec() - started_ms + print("WMO render cache finished. baked=%d skipped=%d failed=%d elapsed=%.2fs" % [ + baked, + skipped, + failed, + float(elapsed_ms) / 1000.0, + ]) + WMO_BUILDER_SCRIPT.clear_caches() + quit(0 if failed == 0 else 2) + + +func _collect_unique_wmos(map_name: String, extracted: String) -> Dictionary: + var unique: Dictionary = {} + var adt_loader = ClassDB.instantiate("ADTLoader") + var map_dir := extracted.path_join("World/Maps/%s" % map_name) + var dir := DirAccess.open(map_dir) + if dir == null: + push_error("Cannot open map dir: %s" % map_dir) + return unique + + print("Scanning ADT tiles...") + var scanned := 0 + for file_name in dir.get_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 + var data: Dictionary = adt_loader.call("load_adt", + ProjectSettings.globalize_path(map_dir.path_join(file_name))) + if data.is_empty(): + continue + for rel in data.get("wmo_names", PackedStringArray()): + var normalized := str(rel).replace("\\", "/").to_lower() + if not normalized.is_empty(): + unique[normalized] = true + scanned += 1 + + print("Found %d unique WMO models from %d tiles." % [unique.size(), scanned]) + return unique + + +func _collect_render_payload( + node: Node, + render_resource: Resource, + parent_transform: Transform3D = Transform3D.IDENTITY) -> void: + if node.name == "Occluders": + return + + var node_transform := parent_transform + if node is Node3D: + node_transform = parent_transform * (node as Node3D).transform + + if node is MeshInstance3D: + var mesh_instance := node as MeshInstance3D + if mesh_instance.mesh != null: + var names: PackedStringArray = render_resource.get("mesh_names") + var meshes: Array = render_resource.get("meshes") + var transforms: Array = render_resource.get("mesh_transforms") + names.append(mesh_instance.name) + meshes.append(mesh_instance.mesh) + transforms.append(node_transform) + render_resource.set("mesh_names", names) + render_resource.set("meshes", meshes) + render_resource.set("mesh_transforms", transforms) + elif node is MultiMeshInstance3D: + var multimesh_instance := node as MultiMeshInstance3D + if multimesh_instance.multimesh != null: + var names: PackedStringArray = render_resource.get("multimesh_names") + var multimeshes: Array = render_resource.get("multimeshes") + var transforms: Array = render_resource.get("multimesh_transforms") + names.append(multimesh_instance.name) + multimeshes.append(multimesh_instance.multimesh) + transforms.append(node_transform) + render_resource.set("multimesh_names", names) + render_resource.set("multimeshes", multimeshes) + render_resource.set("multimesh_transforms", transforms) + + for child in node.get_children(): + _collect_render_payload(child, render_resource, node_transform) + + +func _get_wmo_render_path(output_dir: String, rel_path: String) -> String: + var normalized := rel_path.replace("\\", "/") + return output_dir.path_join(normalized.get_basename() + ".res") + + +func _arg(args: PackedStringArray, name: String, default: String) -> String: + var idx := args.find(name) + if idx >= 0 and idx + 1 < args.size(): + return args[idx + 1] + return default + + +func _res(path: String) -> String: + if path.begins_with("res://") or path.begins_with("user://"): + return path + return "res://" + path.trim_prefix("./").trim_prefix("/") diff --git a/src/tools/build_wmo_streaming_cache.gd.uid b/src/tools/build_wmo_streaming_cache.gd.uid new file mode 100644 index 0000000..3572dcb --- /dev/null +++ b/src/tools/build_wmo_streaming_cache.gd.uid @@ -0,0 +1 @@ +uid://d16p5f4t34jpy diff --git a/third_party/StormLib b/third_party/StormLib index 448dd9a..06d13e5 160000 --- a/third_party/StormLib +++ b/third_party/StormLib @@ -1 +1 @@ -Subproject commit 448dd9a824f60d940a4627f5d2b8a7c31ee9287f +Subproject commit 06d13e5c1b4d6ddf2a57b80c4331f78740d2f462