1606 lines
49 KiB
GDScript
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)
|