Files
open-wc/scenes/streaming_world_loader.gd
T
2026-04-20 20:26:14 +04:00

1606 lines
49 KiB
GDScript

@tool
## Streams Azeroth terrain around the active camera with chunk-based LODs.
extends Node3D
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
const BAKED_TILE_SCRIPT := preload("res://resources/baked_adt_tile.gd")
const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd")
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 4
const TILE_SIZE := 533.33333
const CHUNK_SIZE := TILE_SIZE / 16.0
@export var extracted_dir: String = "res://extracted"
@export var map_name: String = "Azeroth"
@export var camera_path: NodePath
## Seconds between streaming updates (lower = more responsive, higher = cheaper)
@export var update_interval: float = 0.25
## ADT tiles parsed per update tick (increase for faster initial load, decrease to reduce hitching)
@export var tiles_per_tick: int = 2
## Maximum number of ADT parse tasks running on worker threads at once
@export var max_concurrent_tile_tasks: int = 2
## Chunk mesh nodes created OR removed per update tick
@export var chunk_ops_per_tick: int = 24
## Heavy tile LOD creations per update tick
@export var tile_lod_ops_per_tick: int = 1
## 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.
@export var cached_tile_mesh_limit: int = 24
@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 warm_tile_margin: int = 1
@export var auto_position_camera: bool = true
@export var editor_preview_enabled: bool = true
@export var editor_preview_center_x: int = 32
@export var editor_preview_center_y: int = 48
@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 use_rendering_server_for_terrain: bool = true
@export var use_baked_tile_cache: bool = true
@export var baked_tile_cache_dir: String = "res://cache/baked_terrain_v2"
@export var use_chunk_terrain_lods: bool = false
@export var enable_water: bool = false
@export var enable_wmo: bool = true
@export var enable_m2_assets: bool = true
@export var enable_m2_placeholders: bool = false
@export var m2_placeholder_only_trees: bool = true
@export var m2_cache_dir: String = "res://cache/m2_glb"
@export var wmo_cache_dir: String = "res://cache/wmo_tscn"
@export var terrain_cast_shadows: bool = false
@export var m2_cast_shadows: bool = false
@export var editor_reload_now: bool = false
@export var debug_streaming: bool = false
var _builder
var _map_dir := ""
var _abs_map_dir := ""
var _available_tiles: Dictionary = {}
var _tile_states: Dictionary = {}
var _tile_load_queue: Array = []
var _chunk_remove_queue: Array = []
var _chunk_create_queue: Array = []
var _tile_lod_remove_queue: Array = []
var _tile_lod_create_queue: Array = []
var _tile_loading_tasks: Dictionary = {}
var _tile_result_mutex := Mutex.new()
var _tile_result_queue: Array = []
var _shared_tex_cache: Dictionary = {}
var _terrain_root: Node3D
var _update_accum := 0.0
var _tile_min := Vector2i(63, 63)
var _tile_max := Vector2i(0, 0)
var _camera_initialized := false
var _editor_signature := ""
var _last_focus_pos := Vector3.ZERO
var _dbg_chunks_created := 0
var _dbg_chunks_null := 0
var _dbg_last_report := 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_missing_cache: Dictionary = {}
var _m2_scene_cache: Dictionary = {}
var _m2_missing_cache: Dictionary = {}
func _ready() -> void:
if not ClassDB.class_exists("ADTLoader"):
push_error("ADTLoader not found. Rebuild GDExtension first.")
return
_builder = ADT_BUILDER_SCRIPT.new()
_map_dir = extracted_dir.path_join("World/Maps/%s" % map_name)
_abs_map_dir = ProjectSettings.globalize_path(_map_dir)
var existing_terrain := get_node_or_null("Terrain")
if existing_terrain is Node3D:
_terrain_root = existing_terrain
else:
_terrain_root = Node3D.new()
_terrain_root.name = "Terrain"
add_child(_terrain_root)
_reset_terrain_root_children()
_set_editor_owner_recursive(_terrain_root)
_scan_available_tiles()
if _available_tiles.is_empty():
push_error("No ADT tiles found in %s" % _map_dir)
return
if _can_use_baked_tile_cache():
var baked_dir := ProjectSettings.globalize_path(baked_tile_cache_dir.path_join(map_name))
if not DirAccess.dir_exists_absolute(baked_dir):
push_warning("Baked terrain cache not found: %s. Runtime will fall back to raw ADT builds." % baked_dir)
if Engine.is_editor_hint():
_tick_editor_streaming()
elif auto_position_camera:
_position_camera_over_world()
set_process(true)
func _process(delta: float) -> void:
if _available_tiles.is_empty():
return
# Queues are always drained — works in both editor and game.
_drain_tile_load_results()
_process_queues()
_update_accum += delta
if _update_accum < update_interval:
return
_update_accum = 0.0
if Engine.is_editor_hint():
_tick_editor_streaming()
else:
_refresh_streaming_targets(false)
func _exit_tree() -> void:
_shutting_down = true
_wait_for_tile_tasks()
_clear_streamed_world()
func _tick_editor_streaming() -> void:
if editor_reload_now:
editor_reload_now = false
_clear_streamed_world()
_editor_signature = ""
if not editor_preview_enabled:
if not _editor_signature.is_empty():
_clear_streamed_world()
_terrain_root.position = Vector3.ZERO
_editor_signature = ""
return
var sig := _make_editor_signature()
if sig != _editor_signature:
_editor_signature = sig
_clear_streamed_world()
_reset_refresh_focus()
# Offset terrain_root so preview center sits near Godot origin.
var focus := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y)
_terrain_root.position = -focus
var focus_world := _tile_center_to_world(editor_preview_center_x, editor_preview_center_y)
if editor_follow_view_camera:
var editor_camera := _get_stream_camera()
if editor_camera:
focus_world += editor_camera.global_position
_refresh_editor_streaming_targets_at(focus_world, false)
func _scan_available_tiles() -> void:
var dir := DirAccess.open(_map_dir)
if dir == null:
push_error("Cannot open map directory: %s" % _map_dir)
return
for file_name in dir.get_files():
if not file_name.ends_with(".adt"):
continue
var stem := file_name.trim_suffix(".adt")
var parts := stem.split("_")
if parts.size() != 3 or parts[0] != map_name:
continue
if not parts[1].is_valid_int() or not parts[2].is_valid_int():
continue
var tx := parts[1].to_int()
var ty := parts[2].to_int()
var key := _tile_key(tx, ty)
_available_tiles[key] = _abs_map_dir.path_join(file_name)
_tile_min.x = min(_tile_min.x, tx)
_tile_min.y = min(_tile_min.y, ty)
_tile_max.x = max(_tile_max.x, tx)
_tile_max.y = max(_tile_max.y, ty)
print("StreamingWorld: found %d ADT tiles for %s" % [_available_tiles.size(), map_name])
func _refresh_streaming_targets(force: bool) -> void:
var camera := _get_stream_camera()
if camera == null:
return
_terrain_root.position = Vector3.ZERO
_refresh_streaming_targets_at(camera.global_position, force)
## Core streaming update — works for both game (camera pos) and editor (preview center pos).
func _refresh_editor_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
if not force and not _should_refresh_focus(focus_pos):
return
var focus_tile := _world_to_tile(focus_pos)
var total_tile_radius: int = maxi(0, editor_preview_tile_radius)
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
_apply_streaming_target(wanted_tiles, focus_pos)
_note_refresh_focus(focus_pos)
if force or debug_streaming:
print("StreamingWorld(editor): focus_tile=%s wanted=%d queued=%d" % [
focus_tile, wanted_tiles.size(), _tile_load_queue.size()])
func _refresh_streaming_targets_at(focus_pos: Vector3, force: bool) -> void:
if not force and not _should_refresh_focus(focus_pos):
return
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 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
_apply_streaming_target(wanted_tiles, focus_pos)
_note_refresh_focus(focus_pos)
if force or debug_streaming:
print("StreamingWorld: focus_tile=%s wanted=%d queued=%d" % [
focus_tile, wanted_tiles.size(), _tile_load_queue.size()])
func _apply_streaming_target(wanted_tiles: Dictionary, focus_pos: Vector3) -> void:
_last_focus_pos = focus_pos
var filtered_load_queue: Array = []
for request in _tile_load_queue:
if wanted_tiles.has(request["key"]) and not _tile_states.has(request["key"]):
filtered_load_queue.append(request)
_tile_load_queue = filtered_load_queue
for key in wanted_tiles.keys():
if not _tile_states.has(key) and not _is_tile_queued(key):
var parts: PackedStringArray = key.split("_")
var tx: int = int(parts[0])
var ty: int = int(parts[1])
var tcx: float = (tx + 0.5) * TILE_SIZE
var tcz: float = (ty + 0.5) * TILE_SIZE
var dx: float = tcx - focus_pos.x
var dz: float = tcz - focus_pos.z
_tile_load_queue.append({
"key": key,
"tx": tx,
"ty": ty,
"path": _available_tiles[key],
"dist_sq": dx * dx + dz * dz,
})
# Nearest tiles first
_tile_load_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["dist_sq"] < b["dist_sq"])
for key in _tile_states.keys():
var state: Dictionary = _tile_states[key]
var wanted := wanted_tiles.has(key)
state["wanted"] = wanted
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)
else:
state["desired_lods"] = {}
state["desired_tile_lod"] = -1
_tile_states[key] = state
_rebuild_chunk_queues()
func _chunk_origin_for(state: Dictionary, chunk_id: int) -> Vector3:
var chunks: Array = state["data"].get("chunks", [])
if chunk_id >= 0 and chunk_id < chunks.size():
var c: Dictionary = chunks[chunk_id]
if not c.is_empty():
return c.get("origin", Vector3.ZERO)
return state.get("origin", Vector3.ZERO)
func _rebuild_chunk_queues() -> void:
_chunk_remove_queue.clear()
_chunk_create_queue.clear()
_tile_lod_remove_queue.clear()
_tile_lod_create_queue.clear()
for key in _tile_states.keys():
var state: Dictionary = _tile_states[key]
var current_chunks: Dictionary = state["chunks"]
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))
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:
_tile_lod_create_queue.append({
"tile": key,
"lod": desired_tile_lod,
"dist_sq": _tile_dist_sq(state, _last_focus_pos),
})
continue
if current_tile_lod >= 0 and _is_tile_chunk_set_ready(state):
_tile_lod_remove_queue.append({"tile": key})
for chunk_id in current_chunks.keys():
if not desired_lods.has(chunk_id):
_chunk_remove_queue.append({"tile": key, "chunk": chunk_id})
continue
if current_chunks[chunk_id]["lod"] != desired_lods[chunk_id]:
var lod_change_origin: Vector3 = _chunk_origin_for(state, chunk_id)
var dx2: float = lod_change_origin.x - _last_focus_pos.x
var dz2: float = lod_change_origin.z - _last_focus_pos.z
_chunk_create_queue.append({
"tile": key,
"chunk": chunk_id,
"lod": desired_lods[chunk_id],
"dist_sq": dx2 * dx2 + dz2 * dz2,
})
for chunk_id in desired_lods.keys():
if current_chunks.has(chunk_id):
continue
var chunk_origin: Vector3 = _chunk_origin_for(state, chunk_id)
var dx: float = chunk_origin.x - _last_focus_pos.x
var dz: float = chunk_origin.z - _last_focus_pos.z
_chunk_create_queue.append({
"tile": key,
"chunk": chunk_id,
"lod": desired_lods[chunk_id],
"dist_sq": dx * dx + dz * dz,
})
# Sort after fully rebuilding so nearest chunks are processed first
_chunk_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["dist_sq"] < b["dist_sq"])
_tile_lod_create_queue.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a["dist_sq"] < b["dist_sq"])
func _process_queues() -> void:
var ops_left: int = chunk_ops_per_tick
var tile_lod_create_left: int = tile_lod_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():
_remove_tile_lod(_tile_lod_remove_queue.pop_back())
ops_left -= 1
# Parse N ADT files this tick — the main throttle knob
var loads_left: int = tiles_per_tick
while loads_left > 0 and not _tile_load_queue.is_empty() and _tile_loading_tasks.size() < max_concurrent_tile_tasks:
_start_tile_load_async(_tile_load_queue.pop_front())
loads_left -= 1
while tile_lod_create_left > 0 and not _tile_lod_create_queue.is_empty():
_create_tile_lod(_tile_lod_create_queue.pop_front())
tile_lod_create_left -= 1
# Create chunk meshes — sorted nearest-first so visible detail appears first
while ops_left > 0 and not _chunk_create_queue.is_empty():
_create_chunk(_chunk_create_queue.pop_front())
ops_left -= 1
# Debug summary every 2 seconds
_dbg_last_report += get_process_delta_time()
if debug_streaming and _dbg_last_report >= 2.0:
_dbg_last_report = 0.0
print("DBG QUEUES tiles_load=%d tile_lod_create=%d tile_lod_remove=%d chunk_create=%d chunk_remove=%d created=%d null=%d states=%d" % [
_tile_load_queue.size(), _tile_lod_create_queue.size(), _tile_lod_remove_queue.size(),
_chunk_create_queue.size(), _chunk_remove_queue.size(),
_dbg_chunks_created, _dbg_chunks_null, _tile_states.size()])
func _start_tile_load_async(request: Dictionary) -> void:
var key: String = request["key"]
if _tile_loading_tasks.has(key):
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(
baked_res_path,
"",
false,
ResourceLoader.CACHE_MODE_REUSE)
if err == OK:
_tile_loading_tasks[key] = {
"mode": "baked",
"path": baked_res_path,
"request": request,
}
return
_start_raw_tile_load_async(request)
func _start_raw_tile_load_async(request: Dictionary) -> void:
var key: String = request["key"]
if _tile_loading_tasks.has(key):
return
var task_id: int = WorkerThreadPool.add_task(_load_tile_task.bind(request))
_tile_loading_tasks[key] = {
"mode": "raw",
"task_id": task_id,
"request": request,
}
func _load_tile_task(request: Dictionary) -> void:
var data: Dictionary = {}
var loader = ClassDB.instantiate("ADTLoader")
if loader:
data = loader.call("load_adt", request["path"])
_tile_result_mutex.lock()
_tile_result_queue.append({
"key": request["key"],
"data": data,
})
_tile_result_mutex.unlock()
func _drain_tile_load_results() -> void:
var results: Array = []
_tile_result_mutex.lock()
if not _tile_result_queue.is_empty():
results = _tile_result_queue
_tile_result_queue = []
_tile_result_mutex.unlock()
for result in results:
var key: String = result["key"]
if not _tile_loading_tasks.has(key):
continue
var pending: Dictionary = _tile_loading_tasks[key]
if pending.get("mode", "raw") != "raw":
continue
WorkerThreadPool.wait_for_task_completion(int(pending["task_id"]))
_tile_loading_tasks.erase(key)
if _shutting_down:
continue
var request: Dictionary = pending["request"]
if not _should_accept_loaded_tile(request):
continue
_finalize_loaded_tile(request, result["data"])
var baked_ready: Array = []
for key in _tile_loading_tasks.keys():
var pending: Dictionary = _tile_loading_tasks[key]
if pending.get("mode", "raw") != "baked":
continue
var status := ResourceLoader.load_threaded_get_status(pending["path"])
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
baked_ready.append(key)
for key in baked_ready:
if not _tile_loading_tasks.has(key):
continue
var pending: Dictionary = _tile_loading_tasks[key]
_tile_loading_tasks.erase(key)
var request: Dictionary = pending["request"]
if _shutting_down:
continue
var status := ResourceLoader.load_threaded_get_status(pending["path"])
if status != ResourceLoader.THREAD_LOAD_LOADED:
_start_raw_tile_load_async(request)
continue
var baked_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:
push_warning("Invalid baked tile resource: %s" % pending["path"])
_start_raw_tile_load_async(request)
continue
_finalize_loaded_baked_tile(request, baked_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"]))
_tile_loading_tasks.clear()
_tile_result_mutex.lock()
_tile_result_queue.clear()
_tile_result_mutex.unlock()
func _should_accept_loaded_tile(request: Dictionary) -> bool:
if _tile_states.has(request["key"]):
return false
if not _available_tiles.has(request["key"]):
return false
return _is_tile_wanted_now(int(request["tx"]), int(request["ty"]))
func _is_tile_wanted_now(tx: int, ty: int) -> bool:
var focus_tile := _world_to_tile(_last_focus_pos)
var radius: int
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)
return abs(tx - focus_tile.x) <= radius and abs(ty - focus_tile.y) <= radius
func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void:
var key: String = request["key"]
if _tile_states.has(key):
return
if data.is_empty() or not data.has("chunks"):
push_warning("Failed to load ADT tile: %s" % request["path"])
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 = _builder.get_tile_origin_for_data(data)
tile_root.position = tile_origin
if enable_water:
var water_root: Node3D = _builder.build_tile_water_scene(data, tile_origin)
if water_root:
tile_root.add_child(water_root)
_set_editor_owner_recursive(water_root)
if enable_m2_assets:
_build_tile_m2_assets(
tile_root,
tile_origin,
data.get("m2_names", PackedStringArray()),
data.get("m2_placements", []))
if enable_m2_placeholders:
_build_tile_m2_placeholders(
tile_root,
tile_origin,
data.get("m2_names", PackedStringArray()),
data.get("m2_placements", []))
if enable_wmo:
_build_tile_wmos(
tile_root,
tile_origin,
data.get("wmo_names", PackedStringArray()),
data.get("wmo_placements", []))
var state := {
"key": key,
"tx": request["tx"],
"ty": request["ty"],
"path": request["path"],
"origin": tile_origin,
"data": data,
"baked_tile": null,
"tex_names": data.get("textures", PackedStringArray()),
"chunks": {},
"desired_lods": {},
"tile_lod": -1,
"tile_lod_node": null,
"tile_lod_rid": RID(),
"tile_lod_mesh": null,
"desired_tile_lod": -1,
"wanted": true,
"root": tile_root,
}
# 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)
_tile_states[key] = state
_rebuild_chunk_queues()
if debug_streaming:
print("StreamingWorld: loaded tile %d,%d" % [request["tx"], request["ty"]])
func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> void:
var key: String = request["key"]
if _tile_states.has(key):
return
var baked_version: int = int(baked_tile.get("format_version"))
if baked_version < REQUIRED_BAKED_TILE_FORMAT_VERSION:
push_warning(
"Outdated baked tile cache for %s_%d_%d (format=%d, required=%d). Falling back to raw ADT load." % [
map_name, request["tx"], request["ty"], baked_version, REQUIRED_BAKED_TILE_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 = baked_tile.get("tile_origin")
tile_root.position = tile_origin
if enable_m2_assets:
var baked_m2_names_value: Variant = baked_tile.get("m2_names")
var baked_m2_placements_value: Variant = baked_tile.get("m2_placements")
var baked_m2_names: PackedStringArray = baked_m2_names_value if baked_m2_names_value is PackedStringArray else PackedStringArray()
var baked_m2_placements: Array = baked_m2_placements_value if baked_m2_placements_value is Array else []
_build_tile_m2_assets(
tile_root,
tile_origin,
baked_m2_names,
baked_m2_placements)
if enable_wmo:
var baked_wmo_names_value: Variant = baked_tile.get("wmo_names")
var baked_wmo_placements_value: Variant = baked_tile.get("wmo_placements")
var baked_wmo_names: PackedStringArray = baked_wmo_names_value if baked_wmo_names_value is PackedStringArray else PackedStringArray()
var baked_wmo_placements: Array = baked_wmo_placements_value if baked_wmo_placements_value is Array else []
_build_tile_wmos(
tile_root,
tile_origin,
baked_wmo_names,
baked_wmo_placements)
var state := {
"key": key,
"tx": request["tx"],
"ty": request["ty"],
"path": request["path"],
"origin": tile_origin,
"data": {},
"baked_tile": baked_tile,
"tex_names": PackedStringArray(),
"chunks": {},
"desired_lods": {},
"tile_lod": -1,
"tile_lod_node": null,
"tile_lod_rid": RID(),
"tile_lod_mesh": null,
"desired_tile_lod": -1,
"wanted": true,
"root": tile_root,
}
state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos)
_tile_states[key] = state
_rebuild_chunk_queues()
if debug_streaming:
print("StreamingWorld: loaded baked 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):
return
var state: Dictionary = _tile_states[key]
var lod: int = int(op["lod"])
if int(state.get("tile_lod", -1)) == lod:
return
var old_node: Node = state.get("tile_lod_node", null)
var old_rid: RID = state.get("tile_lod_rid", RID())
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)
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():
state["tile_lod_rid"] = instance_rid
state["tile_lod_mesh"] = mesh
if not state["tile_lod_rid"].is_valid() and mesh:
var node := MeshInstance3D.new()
node.name = "TileLOD%d" % lod
node.mesh = mesh
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
state["root"].add_child(node)
_set_editor_owner_recursive(node)
if old_rid.is_valid():
_free_render_instance(old_rid)
if old_node:
old_node.queue_free()
_tile_states[key] = state
func _create_chunk(op: Dictionary) -> void:
var key: String = op["tile"]
if not _tile_states.has(key):
return
var state: Dictionary = _tile_states[key]
var chunk_id: int = int(op["chunk"])
var lod: int = int(op["lod"])
var chunks: Array = state["data"].get("chunks", [])
if chunk_id < 0 or chunk_id >= chunks.size():
return
var chunk: Dictionary = chunks[chunk_id]
if chunk.is_empty():
return
var current_chunks: Dictionary = state["chunks"]
if current_chunks.has(chunk_id):
_remove_chunk({"tile": key, "chunk": chunk_id})
current_chunks = state["chunks"]
var instance_rid := RID()
var node: MeshInstance3D = null
var mesh: Mesh = null
if use_rendering_server_for_terrain:
var payload: Dictionary = _builder.build_chunk_render_payload(
chunk,
state["tex_names"],
_shared_tex_cache,
ProjectSettings.globalize_path(extracted_dir),
lod,
state.get("origin", Vector3.ZERO))
if not payload.is_empty():
mesh = payload.get("mesh", null)
instance_rid = _create_render_instance(
mesh,
_tile_local_to_world_transform(state, payload.get("position", Vector3.ZERO)))
if not instance_rid.is_valid():
node = _builder.build_chunk_scene(
chunk,
state["tex_names"],
_shared_tex_cache,
ProjectSettings.globalize_path(extracted_dir),
lod,
state.get("origin", Vector3.ZERO))
if node == null and not instance_rid.is_valid():
_dbg_chunks_null += 1
current_chunks[chunk_id] = {
"node": null,
"rid": RID(),
"mesh": null,
"lod": lod,
"empty": true,
}
state["chunks"] = current_chunks
_tile_states[key] = state
if debug_streaming and _dbg_chunks_null <= 3:
var h: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
print("DBG NULL chunk tile=%s id=%d lod=%d heights=%d origin=%s" % [
key, chunk_id, lod, h.size(), chunk.get("origin", Vector3.ZERO)])
return
if node:
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if terrain_cast_shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
state["root"].add_child(node)
_set_editor_owner_recursive(node)
_dbg_chunks_created += 1
if debug_streaming and _dbg_chunks_created <= 3:
var dbg_origin := node.global_position if node else _tile_local_to_world_transform(
state,
chunk.get("origin", Vector3.ZERO) - state.get("origin", Vector3.ZERO)).origin
print("DBG CHUNK ADDED tile=%s id=%d lod=%d world_pos=%s rs=%s" % [
key, chunk_id, lod, dbg_origin, instance_rid.is_valid()])
current_chunks[chunk_id] = {
"node": node,
"rid": instance_rid,
"mesh": mesh,
"lod": lod,
}
state["chunks"] = current_chunks
_tile_states[key] = state
func _remove_chunk(op: Dictionary) -> void:
var key: String = op["tile"]
if not _tile_states.has(key):
return
var state: Dictionary = _tile_states[key]
var chunk_id: int = int(op["chunk"])
var current_chunks: Dictionary = state["chunks"]
if not current_chunks.has(chunk_id):
return
var entry: Dictionary = current_chunks[chunk_id]
var node: Node = entry.get("node", null)
var instance_rid: RID = entry.get("rid", RID())
if instance_rid.is_valid():
_free_render_instance(instance_rid)
if node:
node.queue_free()
current_chunks.erase(chunk_id)
state["chunks"] = current_chunks
if not state["wanted"] and current_chunks.is_empty():
_release_tile_if_unused(key)
else:
_tile_states[key] = state
func _remove_tile_lod(op: Dictionary) -> void:
var key: String = op["tile"]
if not _tile_states.has(key):
return
var state: Dictionary = _tile_states[key]
var node: Node = state.get("tile_lod_node", null)
var instance_rid: RID = state.get("tile_lod_rid", RID())
if instance_rid.is_valid():
_free_render_instance(instance_rid)
if node:
node.queue_free()
state["tile_lod_node"] = null
state["tile_lod_rid"] = RID()
state["tile_lod_mesh"] = null
state["tile_lod"] = -1
if not state["wanted"] and state["chunks"].is_empty():
_release_tile_if_unused(key)
else:
_tile_states[key] = state
func _release_tile(key: String) -> void:
if not _tile_states.has(key):
return
var state: Dictionary = _tile_states[key]
var tile_lod_rid: RID = state.get("tile_lod_rid", RID())
if tile_lod_rid.is_valid():
_free_render_instance(tile_lod_rid)
for entry in state.get("chunks", {}).values():
var chunk_rid: RID = entry.get("rid", RID())
if chunk_rid.is_valid():
_free_render_instance(chunk_rid)
var tile_root: Node = state.get("root", null)
if tile_root:
tile_root.queue_free()
_tile_states.erase(key)
func _clear_streamed_world() -> void:
_tile_load_queue.clear()
_chunk_remove_queue.clear()
_chunk_create_queue.clear()
_tile_lod_remove_queue.clear()
_tile_lod_create_queue.clear()
_tile_result_mutex.lock()
_tile_result_queue.clear()
_tile_result_mutex.unlock()
for key in _tile_states.keys():
var state: Dictionary = _tile_states[key]
var tile_lod_rid: RID = state.get("tile_lod_rid", RID())
if tile_lod_rid.is_valid():
_free_render_instance(tile_lod_rid)
for entry in state.get("chunks", {}).values():
var chunk_rid: RID = entry.get("rid", RID())
if chunk_rid.is_valid():
_free_render_instance(chunk_rid)
var tile_root: Node = state.get("root", null)
if tile_root:
tile_root.queue_free()
_tile_states.clear()
_clear_tile_mesh_cache()
func _reset_terrain_root_children() -> void:
if _terrain_root == null:
return
for child in _terrain_root.get_children():
_terrain_root.remove_child(child)
child.free()
func _compute_desired_lods(state: Dictionary, cam_pos: Vector3) -> Dictionary:
if not use_chunk_terrain_lods:
return {}
var desired := {}
var chunks: Array = state["data"].get("chunks", [])
var lod0_sq: float = (lod0_radius_chunks * CHUNK_SIZE) ** 2
var lod1_sq: float = (lod1_radius_chunks * CHUNK_SIZE) ** 2
var lod2_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2
for chunk_id in range(chunks.size()):
var chunk: Dictionary = chunks[chunk_id]
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var center := origin + Vector3(CHUNK_SIZE * 0.5, 0.0, CHUNK_SIZE * 0.5)
var dx := center.x - cam_pos.x
var dz := center.z - cam_pos.z
var dist_sq := dx * dx + dz * dz
if dist_sq <= lod0_sq: desired[chunk_id] = 0
elif dist_sq <= lod1_sq: desired[chunk_id] = 1
elif dist_sq <= lod2_sq: desired[chunk_id] = 2
return desired
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
if not use_chunk_terrain_lods:
if tile_dist_sq <= chunk_radius_sq:
return 0
if tile_dist_sq <= tile_radius_sq:
return 3
return -1
if tile_dist_sq <= chunk_radius_sq:
return -1
if tile_dist_sq <= tile_radius_sq:
return 3
return -1
func _is_tile_chunk_set_ready(state: Dictionary) -> bool:
var desired_lods: Dictionary = state.get("desired_lods", {})
var current_chunks: Dictionary = state.get("chunks", {})
for chunk_id in desired_lods.keys():
if not current_chunks.has(chunk_id):
return false
if int(current_chunks[chunk_id].get("lod", -999)) != int(desired_lods[chunk_id]):
return false
return true
func _tile_dist_sq(state: Dictionary, cam_pos: Vector3) -> float:
var min_x: float = float(state["tx"]) * TILE_SIZE
var max_x: float = min_x + TILE_SIZE
var min_z: float = float(state["ty"]) * TILE_SIZE
var max_z: float = min_z + TILE_SIZE
var dx := 0.0
if cam_pos.x < min_x:
dx = min_x - cam_pos.x
elif cam_pos.x > max_x:
dx = cam_pos.x - max_x
var dz := 0.0
if cam_pos.z < min_z:
dz = min_z - cam_pos.z
elif cam_pos.z > max_z:
dz = cam_pos.z - max_z
return dx * dx + dz * dz
func _is_tile_queued(key: String) -> bool:
if _tile_loading_tasks.has(key):
return true
for request in _tile_load_queue:
if request["key"] == key:
return true
return false
func _get_stream_camera() -> Camera3D:
if Engine.is_editor_hint():
# The 3D editor viewport camera lives in EditorInterface, not get_viewport().
# get_viewport().get_camera_3d() from a @tool script returns whichever Camera3D
# belongs to the edited scene (often null), so editor-view movement is invisible
# unless we reach into EditorInterface.
var vp := EditorInterface.get_editor_viewport_3d(0)
if vp != null:
var cam := vp.get_camera_3d()
if cam:
return cam
if camera_path != NodePath():
var from_path := get_node_or_null(camera_path)
if from_path is Camera3D:
return from_path
var viewport_camera := get_viewport().get_camera_3d()
if viewport_camera:
return viewport_camera
for child in get_children():
if child is Camera3D and child.current:
return child
return null
func _can_use_baked_tile_cache() -> bool:
return use_baked_tile_cache and not use_chunk_terrain_lods
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_baked_tile_mesh(state: Dictionary, lod: int) -> Mesh:
var baked_tile: Resource = state.get("baked_tile", null)
if baked_tile == null:
return null
if lod <= 0:
return baked_tile.get("full_mesh")
return baked_tile.get("coarse_mesh")
func _should_refresh_focus(focus_pos: Vector3) -> bool:
if not _has_refresh_focus:
return true
var current_tile := _world_to_tile(focus_pos)
var last_tile := _world_to_tile(_last_refresh_focus_pos)
if current_tile != last_tile:
return true
var min_distance := maxf(1.0, streaming_update_distance)
return focus_pos.distance_squared_to(_last_refresh_focus_pos) >= min_distance * min_distance
func _note_refresh_focus(focus_pos: Vector3) -> void:
_last_refresh_focus_pos = focus_pos
_has_refresh_focus = true
func _reset_refresh_focus() -> void:
_last_refresh_focus_pos = Vector3.ZERO
_has_refresh_focus = false
func _tile_mesh_cache_key(tile_key: String, lod: int) -> String:
return "%s|%d" % [tile_key, lod]
func _build_tile_wmos(tile_root: Node3D, tile_origin: Vector3, wmo_names: PackedStringArray, wmo_placements: Array) -> void:
if wmo_names.is_empty() or wmo_placements.is_empty():
return
var wmo_root := Node3D.new()
wmo_root.name = "WMOs"
tile_root.add_child(wmo_root)
_set_editor_owner_recursive(wmo_root)
for placement_variant in wmo_placements:
if not (placement_variant is Dictionary):
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():
continue
var rel_path: String = str(wmo_names[name_id]).replace("\\", "/")
var instance := _instantiate_wmo(rel_path, placement, tile_origin)
if instance == null:
continue
wmo_root.add_child(instance)
_set_editor_owner_recursive(instance)
func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> void:
if m2_names.is_empty() or m2_placements.is_empty():
return
var groups: Dictionary = {}
for placement_variant in m2_placements:
if not (placement_variant is Dictionary):
continue
var placement: Dictionary = placement_variant
var name_id: int = int(placement.get("name_id", -1))
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
if normalized.ends_with(".mdx") or normalized.ends_with(".mdl"):
normalized = normalized.get_basename() + ".m2"
if normalized.is_empty():
continue
var pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
var xform := Transform3D(Basis.from_euler(rot).scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos)
if not groups.has(normalized):
groups[normalized] = []
(groups[normalized] as Array).append(xform)
if groups.is_empty():
return
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:
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 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
)
m2_root.add_child(mmi)
_set_editor_owner_recursive(mmi)
if m2_root.get_child_count() == 0:
m2_root.queue_free()
func _get_or_load_m2_mesh(rel_path: String) -> Mesh:
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
func _build_tile_m2_placeholders(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> void:
if m2_names.is_empty() or m2_placements.is_empty():
return
var tree_mesh := CylinderMesh.new()
tree_mesh.top_radius = 0.35
tree_mesh.bottom_radius = 0.45
tree_mesh.height = 6.0
tree_mesh.radial_segments = 6
var prop_mesh := BoxMesh.new()
prop_mesh.size = Vector3(1.2, 1.2, 1.2)
var tree_material := StandardMaterial3D.new()
tree_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
tree_material.albedo_color = Color(0.2, 0.75, 0.2, 1.0)
var prop_material := StandardMaterial3D.new()
prop_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
prop_material.albedo_color = Color(0.8, 0.7, 0.35, 1.0)
var tree_mm := MultiMesh.new()
tree_mm.transform_format = MultiMesh.TRANSFORM_3D
tree_mm.mesh = tree_mesh
var prop_mm := MultiMesh.new()
prop_mm.transform_format = MultiMesh.TRANSFORM_3D
prop_mm.mesh = prop_mesh
var tree_transforms: Array[Transform3D] = []
var prop_transforms: Array[Transform3D] = []
for placement_variant in m2_placements:
if not (placement_variant is Dictionary):
continue
var placement: Dictionary = placement_variant
var name_id: int = int(placement.get("name_id", -1))
if name_id < 0 or name_id >= m2_names.size():
continue
var rel_path := str(m2_names[name_id]).to_lower()
var is_tree := rel_path.contains("tree") or rel_path.contains("bush") or rel_path.contains("shrub") or rel_path.contains("plant")
if m2_placeholder_only_trees and not is_tree:
continue
var local_pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
var xform_basis := Basis.from_euler(rot).scaled(Vector3.ONE * maxf(scale_value, 0.01))
var xf := Transform3D(xform_basis, local_pos)
if is_tree:
tree_transforms.append(xf)
else:
prop_transforms.append(xf)
var root := Node3D.new()
root.name = "M2Placeholders"
if not tree_transforms.is_empty():
tree_mm.instance_count = tree_transforms.size()
for i in tree_transforms.size():
tree_mm.set_instance_transform(i, tree_transforms[i])
var tree_mmi := MultiMeshInstance3D.new()
tree_mmi.name = "Trees"
tree_mmi.multimesh = tree_mm
tree_mmi.material_override = tree_material
root.add_child(tree_mmi)
_set_editor_owner_recursive(tree_mmi)
if not prop_transforms.is_empty():
prop_mm.instance_count = prop_transforms.size()
for i in prop_transforms.size():
prop_mm.set_instance_transform(i, prop_transforms[i])
var prop_mmi := MultiMeshInstance3D.new()
prop_mmi.name = "Props"
prop_mmi.multimesh = prop_mm
prop_mmi.material_override = prop_material
root.add_child(prop_mmi)
_set_editor_owner_recursive(prop_mmi)
if root.get_child_count() > 0:
tile_root.add_child(root)
_set_editor_owner_recursive(root)
func _instantiate_m2(rel_path: String, placement: Dictionary, tile_origin: Vector3) -> Node3D:
var prototype: Node3D = _get_or_load_m2_prototype(rel_path)
if prototype == null:
return null
var node := prototype.duplicate() as Node3D
if node == null:
return null
node.name = rel_path.get_file().get_basename()
node.position = placement.get("pos", Vector3.ZERO) - tile_origin
node.rotation = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
node.scale = Vector3.ONE * scale_value
return node
func _instantiate_wmo(rel_path: String, placement: Dictionary, tile_origin: Vector3) -> Node3D:
var prototype := _get_or_load_wmo_prototype(rel_path)
if prototype == null:
return null
var instance := prototype.duplicate()
instance.name = rel_path.get_file().get_basename()
instance.position = placement.get("pos", Vector3.ZERO) - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
rot.y += PI
instance.rotation = rot
var scale_value: float = float(placement.get("scale", 1.0))
instance.scale = Vector3.ONE * scale_value
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"
if normalized_rel.is_empty():
return null
if _m2_scene_cache.has(normalized_rel):
return _m2_scene_cache[normalized_rel]
if _m2_missing_cache.has(normalized_rel):
return null
# Try pre-baked cache (.tscn first, then legacy .glb)
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 resource: Resource = load(cache_res_path)
if resource is PackedScene:
var node = (resource as PackedScene).instantiate()
if node is Node3D:
_m2_scene_cache[normalized_rel] = node as Node3D
return node as Node3D
break
# Fall back to raw M2Loader
if not ClassDB.class_exists("M2Loader"):
_m2_missing_cache[normalized_rel] = true
return null
var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(normalized_rel))
if not FileAccess.file_exists(abs_path):
_m2_missing_cache[normalized_rel] = true
return null
var loader = ClassDB.instantiate("M2Loader")
var data: Dictionary = loader.call("load_m2", abs_path)
if data.is_empty():
_m2_missing_cache[normalized_rel] = true
return null
var prototype: Node3D = M2_BUILDER_SCRIPT.build(data, extracted_dir)
if prototype == null:
_m2_missing_cache[normalized_rel] = true
return null
_m2_scene_cache[normalized_rel] = prototype
return prototype
func _get_or_load_wmo_prototype(rel_path: String) -> Node3D:
var normalized_rel := rel_path.replace("\\", "/")
if normalized_rel.is_empty():
return null
if _wmo_prototype_cache.has(normalized_rel):
return _wmo_prototype_cache[normalized_rel]
if _wmo_missing_cache.has(normalized_rel):
return null
# Try pre-baked .tscn cache
var cache_path := wmo_cache_dir.path_join(normalized_rel.get_basename() + ".tscn")
if ResourceLoader.exists(cache_path):
var resource: Resource = load(cache_path)
if resource is PackedScene:
var node = (resource as PackedScene).instantiate()
if node is Node3D:
_wmo_prototype_cache[normalized_rel] = node as Node3D
return node as Node3D
# Fall back to live WMOLoader
if not ClassDB.class_exists("WMOLoader"):
_wmo_missing_cache[normalized_rel] = true
return null
var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(normalized_rel))
if not FileAccess.file_exists(abs_path):
_wmo_missing_cache[normalized_rel] = true
return null
var loader = ClassDB.instantiate("WMOLoader")
if loader == null:
return null
var data: Dictionary = loader.call("load_wmo", abs_path)
if data.is_empty():
_wmo_missing_cache[normalized_rel] = true
return null
var prototype: Node3D = WMO_BUILDER_SCRIPT.build(data, extracted_dir)
if prototype == null:
_wmo_missing_cache[normalized_rel] = true
return null
_wmo_prototype_cache[normalized_rel] = prototype
return prototype
func _get_m2_cache_resource_path(rel_path: String, ext: String = ".tscn") -> String:
var normalized := rel_path.replace("\\", "/")
var stem := normalized.get_basename()
return m2_cache_dir.path_join(stem + ext)
func _get_cached_tile_mesh(cache_key: String) -> Mesh:
if not _tile_mesh_cache.has(cache_key):
return null
_touch_tile_mesh_cache(cache_key)
return _tile_mesh_cache[cache_key]
func _store_cached_tile_mesh(cache_key: String, mesh: Mesh) -> void:
if mesh == null:
return
_tile_mesh_cache[cache_key] = mesh
_touch_tile_mesh_cache(cache_key)
_trim_tile_mesh_cache()
func _touch_tile_mesh_cache(cache_key: String) -> void:
_tile_mesh_cache_order.erase(cache_key)
_tile_mesh_cache_order.append(cache_key)
func _trim_tile_mesh_cache() -> void:
var limit := maxi(0, cached_tile_mesh_limit)
while _tile_mesh_cache_order.size() > limit:
var oldest: String = _tile_mesh_cache_order.pop_front()
_tile_mesh_cache.erase(oldest)
func _clear_tile_mesh_cache() -> void:
_tile_mesh_cache.clear()
_tile_mesh_cache_order.clear()
func _position_camera_over_world() -> void:
if _camera_initialized:
return
var camera := _get_stream_camera()
if camera == null:
return
var center_tx: float = (_tile_min.x + _tile_max.x) * 0.5
var center_ty: float = (_tile_min.y + _tile_max.y) * 0.5
var center := _tile_center_to_world(center_tx, center_ty)
var tile_span: int = int(max(_tile_max.x - _tile_min.x + 1, _tile_max.y - _tile_min.y + 1))
var height: float = max(2000.0, float(tile_span) * TILE_SIZE * 0.18)
camera.global_position = center + Vector3(0.0, height, height * 0.2)
camera.look_at(center, Vector3.UP)
_camera_initialized = true
## Tile (tx, ty) NW corner sits at Godot (tx*TILE_SIZE, 0, ty*TILE_SIZE).
## This matches wow_to_godot output: chunk origins ≈ (TX*TILE_SIZE, h, TY*TILE_SIZE).
func _tile_center_to_world(tile_x: float, tile_y: float) -> Vector3:
return Vector3(
(tile_x + 0.5) * TILE_SIZE,
0.0,
(tile_y + 0.5) * TILE_SIZE)
func _world_to_tile(world_pos: Vector3) -> Vector2i:
return Vector2i(
int(floor(world_pos.x / TILE_SIZE)),
int(floor(world_pos.z / TILE_SIZE)))
func _tile_key(tx: int, ty: int) -> String:
return "%d_%d" % [tx, ty]
func _set_editor_owner_recursive(node: Node) -> void:
if not Engine.is_editor_hint():
return
if not editor_persist_generated_nodes:
return
var edited_root := get_tree().edited_scene_root
if edited_root == null:
return
node.owner = edited_root
for child in node.get_children():
_set_editor_owner_recursive(child)
func _make_editor_signature() -> String:
return "%s|%s|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%d|%s" % [
extracted_dir,
map_name,
1 if editor_preview_enabled else 0,
editor_preview_center_x,
editor_preview_center_y,
editor_preview_tile_radius,
1 if editor_follow_view_camera else 0,
lod0_radius_chunks,
lod1_radius_chunks,
lod2_radius_chunks,
lod2_tile_radius,
warm_tile_margin,
1 if enable_m2_assets else 0,
1 if enable_m2_placeholders else 0,
m2_cache_dir,
]
func _release_tile_if_unused(key: String) -> void:
if not _tile_states.has(key):
return
var state: Dictionary = _tile_states[key]
if state["wanted"]:
_tile_states[key] = state
return
if not state["chunks"].is_empty():
_tile_states[key] = state
return
if state.get("tile_lod_node", null) != null:
_tile_states[key] = state
return
if state.get("tile_lod_rid", RID()).is_valid():
_tile_states[key] = state
return
if int(state.get("tile_lod", -1)) >= 0:
_tile_states[key] = state
return
_release_tile(key)
func _tile_local_to_world_transform(state: Dictionary, local_position: Vector3) -> Transform3D:
var root: Node3D = state.get("root", null)
var world_pos := local_position
if root:
world_pos += root.global_position
return Transform3D(Basis.IDENTITY, world_pos)
func _create_render_instance(mesh: Mesh, world_transform: Transform3D) -> RID:
if mesh == null:
return RID()
var world := get_world_3d()
if world == null:
return RID()
var scenario: RID = world.scenario
if not scenario.is_valid():
return RID()
var instance_rid := RenderingServer.instance_create()
RenderingServer.instance_set_base(instance_rid, mesh.get_rid())
RenderingServer.instance_set_scenario(instance_rid, scenario)
RenderingServer.instance_set_transform(instance_rid, world_transform)
RenderingServer.instance_geometry_set_cast_shadows_setting(
instance_rid,
RenderingServer.SHADOW_CASTING_SETTING_ON if terrain_cast_shadows else RenderingServer.SHADOW_CASTING_SETTING_OFF)
return instance_rid
func _free_render_instance(instance_rid: RID) -> void:
if instance_rid.is_valid():
RenderingServer.free_rid(instance_rid)