7815385b3b
Work-Package: M01-RND-STREAMING-FOCUS-001 Agent: sindo-main-codex
5561 lines
191 KiB
GDScript
5561 lines
191 KiB
GDScript
@tool
|
|
## Streams Azeroth terrain around an explicit [StreamingFocus] 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://src/resources/baked_adt_tile.gd")
|
|
const STREAMING_TILE_SCRIPT := preload("res://src/resources/streaming_adt_tile.gd")
|
|
const SPLAT_TILE_SCRIPT := preload("res://src/resources/splat_adt_tile.gd")
|
|
const CONTROL_SPLAT_TILE_SCRIPT := preload("res://src/resources/control_splat_adt_tile.gd")
|
|
const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd")
|
|
const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd")
|
|
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
|
|
const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd")
|
|
const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd")
|
|
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
|
|
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
|
|
const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5
|
|
const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1
|
|
const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3
|
|
const M2_MATERIAL_REFRESH_VERSION := 2
|
|
const WMO_MATERIAL_REFRESH_VERSION := 10
|
|
|
|
const TILE_SIZE := 533.33333
|
|
const CHUNK_SIZE := TILE_SIZE / 16.0
|
|
const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI
|
|
const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
|
|
const QUALITY_CUSTOM := "Custom"
|
|
const QUALITY_PERFORMANCE := "Performance"
|
|
const QUALITY_BALANCED := "Balanced"
|
|
const QUALITY_HIGH := "High"
|
|
|
|
@export var extracted_dir: String = "res://data/extracted"
|
|
@export var map_name: String = "Azeroth"
|
|
## Optional scene adapter source. Any Node3D may provide the runtime focus.
|
|
@export var streaming_focus_source_path: NodePath
|
|
## Camera used only by the optional automatic overview positioning feature.
|
|
@export var camera_path: NodePath
|
|
@export_enum("Custom", "Performance", "Balanced", "High") var quality_preset: String = QUALITY_CUSTOM
|
|
|
|
## Seconds between streaming updates (lower = more responsive, higher = cheaper)
|
|
@export var update_interval: float = 0.25
|
|
## 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
|
|
## Heavy tile LOD removals per update tick.
|
|
@export var tile_lod_remove_ops_per_tick: int = 2
|
|
## Loaded tile resources finalized on the main thread per frame.
|
|
@export var tile_finalize_ops_per_tick: int = 1
|
|
## Full-quality baked terrain tile upgrades finalized per frame.
|
|
@export var terrain_upgrade_finalize_ops_per_tick: int = 1
|
|
## Full-quality terrain tile resources loaded in parallel.
|
|
@export var max_concurrent_terrain_upgrade_tasks: int = 1
|
|
## M2/WMO detail batches attached to loaded tiles per frame.
|
|
@export var detail_asset_ops_per_tick: int = 1
|
|
## M2 MultiMesh groups materialized per frame after background grouping.
|
|
@export var m2_build_groups_per_tick: int = 2
|
|
## Maximum instances written into a single M2 MultiMesh per frame.
|
|
@export var m2_multimesh_batch_size: int = 128
|
|
## Experimental GLB M2 animation is allowed only for simple one-primitive creatures.
|
|
## Complex doodads (for example GryphonRoost) need a native M2 animator because GLB
|
|
## conversion currently breaks their skin/material fidelity.
|
|
@export var enable_m2_animated_instances: bool = true
|
|
## Reject complex animated M2 GLBs until the skinning converter is robust for composite doodads.
|
|
@export var m2_animated_max_primitives: int = 3
|
|
@export var m2_animated_denylist_patterns: PackedStringArray = PackedStringArray(["gryphonroost"])
|
|
@export var m2_animated_allowlist_patterns: PackedStringArray = PackedStringArray([
|
|
"creature/fish/",
|
|
"creature/eagle/",
|
|
"world/critter/",
|
|
])
|
|
## Maximum animated M2 scene instances attached per build step. Keep low to avoid hitches.
|
|
@export var m2_animated_instances_per_tick: int = 8
|
|
## Loaded animated M2 scene resources finalized per frame.
|
|
@export var m2_animation_finalize_ops_per_tick: int = 1
|
|
## Loaded M2 scene resources converted to meshes per frame.
|
|
@export var m2_mesh_finalize_ops_per_tick: int = 1
|
|
## WMO instances attached per frame.
|
|
@export var wmo_build_instances_per_tick: int = 1
|
|
## WMO render-cache mesh groups attached per frame.
|
|
@export var wmo_render_group_ops_per_tick: int = 24
|
|
## Raw ADT water parse tasks running in parallel for cached terrain tiles.
|
|
@export var max_concurrent_water_tasks: int = 1
|
|
## Cached-tile water roots attached per frame.
|
|
@export var water_finalize_ops_per_tick: int = 1
|
|
## Maximum cached WMO .tscn size instantiated while streaming. Larger WMOs need a lightweight render cache.
|
|
@export var wmo_max_runtime_scene_mb: float = 8.0
|
|
## Minimum camera movement before recomputing streaming targets.
|
|
@export var streaming_update_distance: float = CHUNK_SIZE * 2.0
|
|
## Recently built tile meshes kept for quick revisits.
|
|
@export var cached_tile_mesh_limit: int = 24
|
|
## Full-quality terrain meshes kept after an ADT is unloaded, so revisits do not flash back to low quality.
|
|
@export var terrain_quality_mesh_cache_limit: int = 24
|
|
|
|
@export var lod0_radius_chunks: int = 6
|
|
@export var lod1_radius_chunks: int = 12
|
|
@export var lod2_radius_chunks: int = 24
|
|
@export var lod2_tile_radius: int = 3
|
|
@export var warm_tile_margin: int = 1
|
|
## Extra ADT ring loaded and GPU-warmed outside the visible terrain radius.
|
|
@export var prewarm_tile_margin: int = 1
|
|
@export var prewarm_tile_lod_instances: bool = true
|
|
## Extra ADT rings retained after leaving the prewarm radius to avoid boundary churn.
|
|
@export var retain_tile_margin: int = 2
|
|
## Pre-create hidden full terrain LOD this many ADT widths before it becomes visible.
|
|
@export var full_lod_prewarm_tiles: float = 1.0
|
|
## Start loading the next ADT-centered ring before crossing a tile boundary.
|
|
@export_range(0.0, 0.49, 0.01) var boundary_prefetch_threshold: float = 0.35
|
|
@export var auto_position_camera: bool = true
|
|
@export var editor_preview_enabled: bool = true
|
|
@export var editor_preview_center_x: int = 32
|
|
@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 enable_occlusion_culling: bool = true
|
|
@export var use_rendering_server_for_terrain: bool = true
|
|
@export var use_baked_tile_cache: bool = true
|
|
@export var baked_tile_cache_dir: String = "res://data/cache/baked_terrain_v2"
|
|
@export var streaming_tile_cache_dir: String = "res://data/cache/baked_terrain_stream_v1"
|
|
@export var terrain_control_splat_cache_dir: String = "res://data/cache/terrain_control_splat_v3"
|
|
@export var terrain_splat_cache_dir: String = "res://data/cache/terrain_splat_v1"
|
|
@export var terrain_full_quality_enabled: bool = true
|
|
@export var terrain_full_quality_radius_tiles: int = 1
|
|
@export var terrain_control_splat_quality_enabled: bool = true
|
|
@export var terrain_control_splat_primary: bool = true
|
|
@export var terrain_control_splat_hide_fallback: bool = false
|
|
@export var terrain_control_splat_hide_fallback_radius_tiles: int = 0
|
|
@export var terrain_control_splat_radius_tiles: int = 0
|
|
@export var max_active_terrain_control_splat_tiles: int = 5
|
|
@export var terrain_control_splat_cache_finalize_ops_per_tick: int = 1
|
|
@export var terrain_splat_quality_enabled: bool = true
|
|
@export var terrain_splat_radius_tiles: int = 0
|
|
@export var terrain_splat_lod: int = 0
|
|
@export var terrain_splat_runtime_build_enabled: bool = false
|
|
@export var max_active_terrain_splat_tiles: int = 5
|
|
@export var terrain_splat_cache_finalize_ops_per_tick: int = 1
|
|
@export var max_concurrent_terrain_splat_tasks: int = 1
|
|
@export var terrain_splat_builds_per_tick: int = 1
|
|
@export var use_chunk_terrain_lods: bool = false
|
|
@export var enable_water: bool = false
|
|
@export var enable_wmo: bool = true
|
|
@export var enable_m2_assets: bool = true
|
|
@export var enable_m2_placeholders: bool = false
|
|
@export var m2_placeholder_only_trees: bool = true
|
|
## Dynamic doodad render radius in ADT tiles. Negative keeps all loaded M2s active.
|
|
@export var m2_tile_radius: int = 1
|
|
## Dynamic WMO render radius in ADT tiles. Negative keeps all loaded WMOs active.
|
|
@export var wmo_tile_radius: int = 3
|
|
## Godot visibility range applied to instantiated M2 MultiMesh nodes. <= 0 disables it.
|
|
@export var m2_visibility_range: float = 700.0
|
|
## Godot visibility range applied to instantiated WMO trees. <= 0 disables it.
|
|
@export var wmo_visibility_range: float = 1800.0
|
|
@export var wmo_render_cache_dir: String = "res://data/cache/wmo_render_v1"
|
|
@export var m2_cache_dir: String = "res://data/cache/m2_glb"
|
|
@export var wmo_cache_dir: String = "res://data/cache/wmo_tscn"
|
|
@export var terrain_cast_shadows: bool = false
|
|
@export var m2_cast_shadows: bool = false
|
|
@export var wmo_cast_shadows: bool = false
|
|
@export var editor_reload_now: bool = false
|
|
@export var debug_streaming: bool = false
|
|
@export var runtime_stats_enabled: bool = false
|
|
@export var runtime_stats_interval: float = 2.0
|
|
@export var terrain_quality_log_enabled: bool = true
|
|
@export var terrain_quality_log_interval: float = 1.0
|
|
@export var hitch_profiler_enabled: bool = false
|
|
@export var hitch_profiler_threshold_ms: float = 20.0
|
|
|
|
var _builder
|
|
var _map_dir := ""
|
|
var _abs_map_dir := ""
|
|
var _wdt_info: Dictionary = {}
|
|
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 _detail_asset_queue: Array = []
|
|
var _detail_asset_queued: Dictionary = {}
|
|
var _m2_group_tasks: Dictionary = {}
|
|
var _m2_group_result_mutex := Mutex.new()
|
|
var _m2_group_result_queue: Array = []
|
|
var _m2_build_jobs: Dictionary = {}
|
|
var _m2_build_queue: Array = []
|
|
var _m2_unique_registry: Dictionary = {}
|
|
var _m2_mesh_cache: Dictionary = {}
|
|
var _m2_mesh_load_requests: Dictionary = {}
|
|
var _m2_mesh_finalize_queue: Array = []
|
|
var _m2_runtime_rebuild_required_cache: Dictionary = {}
|
|
var _m2_animation_load_requests: Dictionary = {}
|
|
var _m2_animation_finalize_queue: Array = []
|
|
var _wmo_build_jobs: Dictionary = {}
|
|
var _wmo_build_queue: Array = []
|
|
var _tile_loading_tasks: Dictionary = {}
|
|
var _terrain_upgrade_tasks: Dictionary = {}
|
|
var _terrain_control_splat_cache_tasks: Dictionary = {}
|
|
var _terrain_splat_cache_tasks: Dictionary = {}
|
|
var _terrain_splat_tasks: Dictionary = {}
|
|
var _terrain_splat_result_mutex := Mutex.new()
|
|
var _terrain_splat_result_queue: Array = []
|
|
var _water_load_queue: Array = []
|
|
var _water_load_queued: Dictionary = {}
|
|
var _water_load_tasks: Dictionary = {}
|
|
var _water_result_mutex := Mutex.new()
|
|
var _water_result_queue: Array = []
|
|
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 _streaming_focus: StreamingFocus
|
|
var _missing_focus_source_reported := false
|
|
var _last_focus_pos := Vector3.ZERO
|
|
var _dbg_chunks_created := 0
|
|
var _dbg_chunks_null := 0
|
|
var _dbg_last_report := 0.0
|
|
var _runtime_stats_accum := 0.0
|
|
var _terrain_quality_log_accum := 0.0
|
|
var _shutting_down := false
|
|
var _last_refresh_focus_pos := Vector3.ZERO
|
|
var _has_refresh_focus := false
|
|
var _tile_mesh_cache: Dictionary = {}
|
|
var _tile_mesh_cache_order: Array = []
|
|
var _terrain_quality_mesh_cache: Dictionary = {}
|
|
var _terrain_quality_mesh_cache_order: Array = []
|
|
var _wmo_prototype_cache: Dictionary = {}
|
|
var _wmo_render_cache: Dictionary = {}
|
|
var _wmo_render_missing_cache: Dictionary = {}
|
|
var _wmo_render_load_requests: Dictionary = {}
|
|
var _wmo_render_build_jobs: Dictionary = {}
|
|
var _wmo_render_build_queue: Array = []
|
|
var _wmo_scene_resource_cache: Dictionary = {}
|
|
var _wmo_scene_cache_missing: Dictionary = {}
|
|
var _wmo_scene_load_requests: Dictionary = {}
|
|
var _wmo_missing_cache: Dictionary = {}
|
|
var _m2_scene_cache: Dictionary = {}
|
|
var _m2_animated_scene_cache: Dictionary = {}
|
|
var _m2_static_animation_cache: Dictionary = {}
|
|
var _m2_missing_cache: Dictionary = {}
|
|
var _world_wmo_root: Node3D
|
|
var _wmo_registry: Dictionary = {}
|
|
|
|
|
|
func _ready() -> void:
|
|
_apply_quality_preset()
|
|
|
|
if not ClassDB.class_exists("ADTLoader"):
|
|
push_error("ADTLoader not found. Rebuild GDExtension first.")
|
|
return
|
|
|
|
if enable_occlusion_culling:
|
|
get_viewport().set_use_occlusion_culling(true)
|
|
|
|
_builder = ADT_BUILDER_SCRIPT.new()
|
|
_map_dir = extracted_dir.path_join("World/Maps/%s" % map_name)
|
|
_abs_map_dir = ProjectSettings.globalize_path(_map_dir)
|
|
|
|
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()
|
|
_ensure_world_wmo_root()
|
|
_set_editor_owner_recursive(_terrain_root)
|
|
|
|
_scan_available_tiles()
|
|
if _available_tiles.is_empty() and not _wdt_has_global_wmo():
|
|
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 terrain_control_splat_quality_enabled:
|
|
var control_splat_dir := ProjectSettings.globalize_path(terrain_control_splat_cache_dir.path_join(map_name))
|
|
if not DirAccess.dir_exists_absolute(control_splat_dir):
|
|
push_warning("Control splat terrain cache not found: %s. High terrain quality will use baked atlas fallback." % control_splat_dir)
|
|
if terrain_splat_quality_enabled:
|
|
var splat_dir := ProjectSettings.globalize_path(terrain_splat_cache_dir.path_join(map_name))
|
|
if not DirAccess.dir_exists_absolute(splat_dir):
|
|
push_warning("Terrain splat cache not found: %s. High terrain quality will use baked atlas fallback." % splat_dir)
|
|
|
|
if enable_wmo:
|
|
_place_global_wmo()
|
|
|
|
if Engine.is_editor_hint():
|
|
_tick_editor_streaming()
|
|
elif auto_position_camera:
|
|
_position_camera_over_world()
|
|
if not Engine.is_editor_hint():
|
|
_capture_streaming_focus_from_source()
|
|
|
|
set_process(true)
|
|
call_deferred("_refresh_streaming_targets_after_ready")
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if _available_tiles.is_empty():
|
|
return
|
|
|
|
var profile_enabled := hitch_profiler_enabled and not Engine.is_editor_hint()
|
|
var profile_start := Time.get_ticks_usec() if profile_enabled else 0
|
|
var section_start := profile_start
|
|
var timings: Array[String] = []
|
|
|
|
# Queues are always drained — works in both editor and game.
|
|
_drain_tile_load_results()
|
|
_profile_section(timings, "finalize", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_drain_terrain_upgrade_results()
|
|
_profile_section(timings, "terrainup", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_drain_terrain_control_splat_cache_results()
|
|
_profile_section(timings, "terrainctl", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_drain_terrain_splat_cache_results()
|
|
_profile_section(timings, "terrainsplatcache", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_drain_terrain_splat_results()
|
|
_profile_section(timings, "terrainsplat", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_process_water_load_queue()
|
|
_drain_water_load_results()
|
|
_profile_section(timings, "water", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_process_queues()
|
|
_profile_section(timings, "terrainq", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_drain_m2_group_results()
|
|
_profile_section(timings, "m2results", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_drain_m2_animation_loads()
|
|
_profile_section(timings, "m2anim", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_drain_m2_mesh_loads()
|
|
_profile_section(timings, "m2mesh", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_process_m2_build_jobs()
|
|
_profile_section(timings, "m2build", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_process_wmo_build_jobs()
|
|
_profile_section(timings, "wmobuild", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_process_wmo_render_build_jobs()
|
|
_profile_section(timings, "wmogroups", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_process_detail_asset_queue()
|
|
_profile_section(timings, "details", section_start, profile_enabled)
|
|
section_start = Time.get_ticks_usec() if profile_enabled else 0
|
|
_tick_runtime_stats(delta)
|
|
_tick_terrain_quality_log(delta)
|
|
|
|
_update_accum += delta
|
|
var did_refresh := false
|
|
if _update_accum < update_interval:
|
|
_log_hitch_profile(profile_start, timings, did_refresh, profile_enabled)
|
|
return
|
|
_update_accum = 0.0
|
|
|
|
if Engine.is_editor_hint():
|
|
_tick_editor_streaming()
|
|
else:
|
|
refresh_streaming_focus(false)
|
|
did_refresh = true
|
|
_profile_section(timings, "refresh", section_start, profile_enabled)
|
|
_log_hitch_profile(profile_start, timings, did_refresh, profile_enabled)
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
_shutting_down = true
|
|
_wait_for_tile_tasks()
|
|
_clear_streamed_world()
|
|
_release_runtime_caches_for_shutdown()
|
|
|
|
|
|
## Frees detached prototype nodes and releases render resources owned by this
|
|
## loader after all asynchronous work has completed. Map refreshes retain these
|
|
## caches; only scene shutdown destroys them.
|
|
func _release_runtime_caches_for_shutdown() -> void:
|
|
_free_detached_node_cache(_m2_scene_cache)
|
|
_free_detached_node_cache(_m2_animated_scene_cache)
|
|
_free_detached_node_cache(_wmo_prototype_cache)
|
|
_m2_mesh_cache.clear()
|
|
_m2_static_animation_cache.clear()
|
|
_m2_missing_cache.clear()
|
|
_wmo_render_cache.clear()
|
|
_wmo_scene_resource_cache.clear()
|
|
_wmo_render_missing_cache.clear()
|
|
_wmo_scene_cache_missing.clear()
|
|
_wmo_missing_cache.clear()
|
|
_shared_tex_cache.clear()
|
|
|
|
|
|
func _free_detached_node_cache(node_cache: Dictionary) -> void:
|
|
for cached_value in node_cache.values():
|
|
if not (cached_value is Node):
|
|
continue
|
|
var cached_node := cached_value as Node
|
|
if not is_instance_valid(cached_node):
|
|
continue
|
|
if cached_node.is_inside_tree():
|
|
cached_node.queue_free()
|
|
else:
|
|
cached_node.free()
|
|
node_cache.clear()
|
|
|
|
|
|
func _apply_quality_preset() -> void:
|
|
match quality_preset:
|
|
QUALITY_PERFORMANCE:
|
|
update_interval = 0.12
|
|
tiles_per_tick = 1
|
|
max_concurrent_tile_tasks = 1
|
|
chunk_ops_per_tick = 16
|
|
tile_lod_ops_per_tick = 1
|
|
tile_lod_remove_ops_per_tick = 1
|
|
tile_finalize_ops_per_tick = 1
|
|
terrain_upgrade_finalize_ops_per_tick = 1
|
|
max_concurrent_terrain_upgrade_tasks = 1
|
|
detail_asset_ops_per_tick = 1
|
|
m2_build_groups_per_tick = 1
|
|
m2_multimesh_batch_size = 32
|
|
m2_animation_finalize_ops_per_tick = 1
|
|
m2_mesh_finalize_ops_per_tick = 1
|
|
wmo_build_instances_per_tick = 1
|
|
wmo_render_group_ops_per_tick = 16
|
|
terrain_quality_mesh_cache_limit = 0
|
|
lod0_radius_chunks = 6
|
|
lod1_radius_chunks = 12
|
|
lod2_radius_chunks = 24
|
|
lod2_tile_radius = 3
|
|
warm_tile_margin = 1
|
|
prewarm_tile_margin = 2
|
|
retain_tile_margin = 3
|
|
full_lod_prewarm_tiles = 1.0
|
|
boundary_prefetch_threshold = 0.4
|
|
terrain_full_quality_enabled = false
|
|
terrain_full_quality_radius_tiles = 0
|
|
terrain_control_splat_quality_enabled = false
|
|
terrain_control_splat_primary = false
|
|
terrain_control_splat_hide_fallback = false
|
|
terrain_control_splat_hide_fallback_radius_tiles = 0
|
|
terrain_control_splat_radius_tiles = 0
|
|
max_active_terrain_control_splat_tiles = 0
|
|
terrain_control_splat_cache_finalize_ops_per_tick = 1
|
|
terrain_splat_quality_enabled = false
|
|
terrain_splat_radius_tiles = 0
|
|
terrain_splat_lod = 3
|
|
terrain_splat_runtime_build_enabled = false
|
|
max_active_terrain_splat_tiles = 0
|
|
terrain_splat_cache_finalize_ops_per_tick = 1
|
|
max_concurrent_terrain_splat_tasks = 1
|
|
terrain_splat_builds_per_tick = 1
|
|
m2_tile_radius = 1
|
|
wmo_tile_radius = 3
|
|
m2_visibility_range = 700.0
|
|
wmo_visibility_range = 1800.0
|
|
m2_cast_shadows = false
|
|
wmo_cast_shadows = false
|
|
terrain_cast_shadows = false
|
|
QUALITY_BALANCED:
|
|
update_interval = 0.10
|
|
tiles_per_tick = 1
|
|
max_concurrent_tile_tasks = 1
|
|
chunk_ops_per_tick = 20
|
|
tile_lod_ops_per_tick = 1
|
|
tile_lod_remove_ops_per_tick = 1
|
|
tile_finalize_ops_per_tick = 1
|
|
terrain_upgrade_finalize_ops_per_tick = 1
|
|
max_concurrent_terrain_upgrade_tasks = 1
|
|
detail_asset_ops_per_tick = 1
|
|
m2_build_groups_per_tick = 1
|
|
m2_multimesh_batch_size = 32
|
|
m2_animation_finalize_ops_per_tick = 1
|
|
m2_mesh_finalize_ops_per_tick = 1
|
|
wmo_build_instances_per_tick = 1
|
|
wmo_render_group_ops_per_tick = 20
|
|
terrain_quality_mesh_cache_limit = 24
|
|
lod0_radius_chunks = 8
|
|
lod1_radius_chunks = 16
|
|
lod2_radius_chunks = 32
|
|
lod2_tile_radius = 4
|
|
warm_tile_margin = 1
|
|
prewarm_tile_margin = 2
|
|
retain_tile_margin = 3
|
|
full_lod_prewarm_tiles = 1.0
|
|
boundary_prefetch_threshold = 0.4
|
|
terrain_full_quality_enabled = true
|
|
terrain_full_quality_radius_tiles = 1
|
|
terrain_control_splat_quality_enabled = true
|
|
terrain_control_splat_primary = true
|
|
terrain_control_splat_hide_fallback = false
|
|
terrain_control_splat_hide_fallback_radius_tiles = 0
|
|
terrain_control_splat_radius_tiles = 0
|
|
max_active_terrain_control_splat_tiles = 1
|
|
terrain_control_splat_cache_finalize_ops_per_tick = 1
|
|
terrain_splat_quality_enabled = false
|
|
terrain_splat_radius_tiles = 0
|
|
terrain_splat_lod = 0
|
|
terrain_splat_runtime_build_enabled = false
|
|
max_active_terrain_splat_tiles = 1
|
|
terrain_splat_cache_finalize_ops_per_tick = 1
|
|
max_concurrent_terrain_splat_tasks = 1
|
|
terrain_splat_builds_per_tick = 1
|
|
m2_tile_radius = 2
|
|
wmo_tile_radius = 4
|
|
m2_visibility_range = 1100.0
|
|
wmo_visibility_range = 2600.0
|
|
m2_cast_shadows = false
|
|
wmo_cast_shadows = false
|
|
terrain_cast_shadows = false
|
|
QUALITY_HIGH:
|
|
update_interval = 0.10
|
|
tiles_per_tick = 1
|
|
max_concurrent_tile_tasks = 1
|
|
chunk_ops_per_tick = 24
|
|
tile_lod_ops_per_tick = 1
|
|
tile_lod_remove_ops_per_tick = 1
|
|
tile_finalize_ops_per_tick = 1
|
|
terrain_upgrade_finalize_ops_per_tick = 1
|
|
max_concurrent_terrain_upgrade_tasks = 1
|
|
detail_asset_ops_per_tick = 1
|
|
m2_build_groups_per_tick = 1
|
|
m2_multimesh_batch_size = 64
|
|
m2_animation_finalize_ops_per_tick = 1
|
|
m2_mesh_finalize_ops_per_tick = 1
|
|
wmo_build_instances_per_tick = 1
|
|
wmo_render_group_ops_per_tick = 16
|
|
terrain_quality_mesh_cache_limit = 48
|
|
lod0_radius_chunks = 10
|
|
lod1_radius_chunks = 20
|
|
lod2_radius_chunks = 40
|
|
lod2_tile_radius = 5
|
|
warm_tile_margin = 1
|
|
prewarm_tile_margin = 3
|
|
retain_tile_margin = 4
|
|
full_lod_prewarm_tiles = 1.5
|
|
boundary_prefetch_threshold = 0.42
|
|
terrain_full_quality_enabled = true
|
|
terrain_full_quality_radius_tiles = 5
|
|
terrain_control_splat_quality_enabled = true
|
|
terrain_control_splat_primary = true
|
|
terrain_control_splat_hide_fallback = true
|
|
terrain_control_splat_hide_fallback_radius_tiles = 0
|
|
terrain_control_splat_radius_tiles = 5
|
|
max_active_terrain_control_splat_tiles = 121
|
|
terrain_control_splat_cache_finalize_ops_per_tick = 8
|
|
terrain_splat_quality_enabled = false
|
|
terrain_splat_radius_tiles = 0
|
|
terrain_splat_lod = 0
|
|
terrain_splat_runtime_build_enabled = false
|
|
max_active_terrain_splat_tiles = 5
|
|
terrain_splat_cache_finalize_ops_per_tick = 1
|
|
max_concurrent_terrain_splat_tasks = 1
|
|
terrain_splat_builds_per_tick = 1
|
|
m2_tile_radius = 3
|
|
wmo_tile_radius = 5
|
|
m2_visibility_range = 1600.0
|
|
wmo_visibility_range = 3600.0
|
|
m2_cast_shadows = false
|
|
wmo_cast_shadows = false
|
|
terrain_cast_shadows = false
|
|
|
|
|
|
func _tick_editor_streaming() -> void:
|
|
if editor_reload_now:
|
|
editor_reload_now = false
|
|
_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_editor_view_camera()
|
|
if editor_camera:
|
|
focus_world += editor_camera.global_position
|
|
_set_streaming_focus_from_vector3(focus_world)
|
|
_refresh_editor_streaming_targets_at(_streaming_focus_to_vector3(), false)
|
|
|
|
|
|
func _scan_available_tiles() -> void:
|
|
_available_tiles.clear()
|
|
_wdt_info = {}
|
|
|
|
if _load_tiles_from_wdt():
|
|
print("StreamingWorld: loaded %d ADT tiles from WDT for %s" % [_available_tiles.size(), map_name])
|
|
return
|
|
|
|
push_warning("StreamingWorld: falling back to directory scan for %s (no WDT)" % map_name)
|
|
_load_tiles_from_directory()
|
|
print("StreamingWorld: found %d ADT tiles by directory scan for %s" % [_available_tiles.size(), map_name])
|
|
|
|
|
|
func _load_tiles_from_wdt() -> bool:
|
|
if not ClassDB.class_exists("WDTLoader"):
|
|
return false
|
|
|
|
var wdt_path_abs := _abs_map_dir.path_join("%s.wdt" % map_name)
|
|
if not FileAccess.file_exists(wdt_path_abs):
|
|
return false
|
|
|
|
var loader = ClassDB.instantiate("WDTLoader")
|
|
if loader == null:
|
|
return false
|
|
|
|
var data: Dictionary = loader.call("load_wdt", wdt_path_abs)
|
|
if data.is_empty():
|
|
push_warning("StreamingWorld: WDTLoader returned empty data for %s" % wdt_path_abs)
|
|
return false
|
|
|
|
var tiles: Array = data.get("tiles", [])
|
|
for tile_variant in tiles:
|
|
if not (tile_variant is Dictionary):
|
|
continue
|
|
var entry: Dictionary = tile_variant
|
|
var tx: int = int(entry.get("x", -1))
|
|
var ty: int = int(entry.get("y", -1))
|
|
if tx < 0 or tx > 63 or ty < 0 or ty > 63:
|
|
continue
|
|
|
|
var adt_file := "%s_%d_%d.adt" % [map_name, tx, ty]
|
|
var adt_abs := _abs_map_dir.path_join(adt_file)
|
|
# WDT flags the tile as present, but the ADT may still be missing in
|
|
# a partial extraction. Skip so downstream code never chases a ghost.
|
|
if not FileAccess.file_exists(adt_abs):
|
|
continue
|
|
|
|
var key := _tile_key(tx, ty)
|
|
_available_tiles[key] = adt_abs
|
|
_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)
|
|
|
|
_wdt_info = data
|
|
return not _available_tiles.is_empty()
|
|
|
|
|
|
func _load_tiles_from_directory() -> 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)
|
|
|
|
|
|
## Replaces the current streaming point without requiring a Node or Camera3D.
|
|
func set_streaming_focus(streaming_focus: StreamingFocus) -> void:
|
|
_streaming_focus = streaming_focus
|
|
|
|
|
|
## Samples the configured scene source, then refreshes streaming from the typed focus.
|
|
## Returns false when no valid focus is available; existing streamed content is retained.
|
|
func refresh_streaming_focus(force: bool = false) -> bool:
|
|
_capture_streaming_focus_from_source()
|
|
if _streaming_focus == null or _streaming_focus.world_position == null:
|
|
return false
|
|
_terrain_root.position = Vector3.ZERO
|
|
_refresh_streaming_targets_at(_streaming_focus_to_vector3(), force)
|
|
return true
|
|
|
|
|
|
func _refresh_streaming_targets_after_ready() -> void:
|
|
_has_refresh_focus = false
|
|
refresh_streaming_focus(false)
|
|
|
|
|
|
## Core streaming update for runtime and editor focus positions.
|
|
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, 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 focus_tiles := _predictive_focus_tiles(focus_pos)
|
|
var total_tile_radius: int = _runtime_load_tile_radius()
|
|
var retain_tile_radius: int = _runtime_retain_tile_radius()
|
|
|
|
var retained_tiles: Dictionary = {}
|
|
for center in focus_tiles:
|
|
for ty in range(center.y - retain_tile_radius, center.y + retain_tile_radius + 1):
|
|
for tx in range(center.x - retain_tile_radius, center.x + retain_tile_radius + 1):
|
|
var retained_key := _tile_key(tx, ty)
|
|
if _available_tiles.has(retained_key):
|
|
retained_tiles[retained_key] = true
|
|
|
|
var wanted_tiles: Dictionary = {}
|
|
for center in focus_tiles:
|
|
for ty in range(center.y - total_tile_radius, center.y + total_tile_radius + 1):
|
|
for tx in range(center.x - total_tile_radius, center.x + total_tile_radius + 1):
|
|
var key := _tile_key(tx, ty)
|
|
if _available_tiles.has(key):
|
|
wanted_tiles[key] = true
|
|
|
|
_apply_streaming_target(wanted_tiles, retained_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, retained_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)
|
|
var retained := retained_tiles.has(key)
|
|
state["wanted"] = wanted
|
|
state["retained"] = retained
|
|
if wanted:
|
|
state["desired_lods"] = _compute_desired_lods(state, focus_pos)
|
|
state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
elif retained:
|
|
state["desired_lods"] = {}
|
|
var current_tile_lod: int = int(state.get("tile_lod", -1))
|
|
state["desired_tile_lod"] = current_tile_lod if current_tile_lod >= 0 else -1
|
|
state["desired_tile_lod_visible"] = false
|
|
else:
|
|
state["desired_lods"] = {}
|
|
state["desired_tile_lod"] = -1
|
|
state["desired_tile_lod_visible"] = false
|
|
state = _sync_terrain_quality(state, focus_pos)
|
|
state = _sync_detail_assets(state, focus_pos)
|
|
_tile_states[key] = state
|
|
|
|
_prune_active_terrain_control_splat_tiles(focus_pos)
|
|
_prune_active_terrain_splat_tiles(focus_pos)
|
|
_rebuild_chunk_queues()
|
|
|
|
|
|
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))
|
|
var current_tile_lod_visible: bool = bool(state.get("tile_lod_visible", false))
|
|
var desired_tile_lod_visible: bool = bool(state.get("desired_tile_lod_visible", false))
|
|
|
|
if desired_tile_lod >= 0:
|
|
for chunk_id in current_chunks.keys():
|
|
_chunk_remove_queue.append({"tile": key, "chunk": chunk_id})
|
|
|
|
if current_tile_lod != desired_tile_lod or current_tile_lod_visible != desired_tile_lod_visible:
|
|
_tile_lod_create_queue.append({
|
|
"tile": key,
|
|
"lod": desired_tile_lod,
|
|
"visible": desired_tile_lod_visible,
|
|
"dist_sq": _tile_dist_sq(state, _last_focus_pos),
|
|
})
|
|
elif _should_prepare_full_tile_lod(state, _last_focus_pos):
|
|
_tile_lod_create_queue.append({
|
|
"tile": key,
|
|
"lod": 0,
|
|
"visible": false,
|
|
"prepare": true,
|
|
"dist_sq": _tile_dist_sq(state, _last_focus_pos),
|
|
})
|
|
continue
|
|
|
|
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
|
|
var tile_lod_remove_left: int = tile_lod_remove_ops_per_tick
|
|
|
|
# Free stale terrain first so GPU descriptors/memory are reclaimed before new creates.
|
|
while ops_left > 0 and not _chunk_remove_queue.is_empty():
|
|
_remove_chunk(_chunk_remove_queue.pop_back())
|
|
ops_left -= 1
|
|
|
|
while tile_lod_remove_left > 0 and not _tile_lod_remove_queue.is_empty():
|
|
_remove_tile_lod(_tile_lod_remove_queue.pop_back())
|
|
tile_lod_remove_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 _profile_section(timings: Array[String], label: String, start_usec: int, enabled: bool) -> void:
|
|
if not enabled:
|
|
return
|
|
var elapsed_ms := float(Time.get_ticks_usec() - start_usec) / 1000.0
|
|
if elapsed_ms >= 0.05:
|
|
timings.append("%s=%.2f" % [label, elapsed_ms])
|
|
|
|
|
|
func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bool, enabled: bool) -> void:
|
|
if not enabled:
|
|
return
|
|
var total_ms := float(Time.get_ticks_usec() - start_usec) / 1000.0
|
|
if total_ms < maxf(1.0, hitch_profiler_threshold_ms):
|
|
return
|
|
print("HITCH %.2fms refresh=%s queues[tile=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d water=%d detail=%d m2task=%d m2anim=%d m2mesh=%d m2build=%d wmobuild=%d wmogroups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] %s" % [
|
|
total_ms,
|
|
str(did_refresh),
|
|
_tile_load_queue.size(),
|
|
_tile_loading_tasks.size(),
|
|
_terrain_upgrade_tasks.size(),
|
|
_terrain_control_splat_cache_tasks.size(),
|
|
_terrain_splat_queue_size(),
|
|
_water_load_queue.size() + _water_load_tasks.size(),
|
|
_detail_asset_queue.size(),
|
|
_m2_group_tasks.size(),
|
|
_m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(),
|
|
_m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(),
|
|
_m2_build_queue.size(),
|
|
_wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(),
|
|
_wmo_render_build_queue.size(),
|
|
_tile_lod_create_queue.size(),
|
|
_tile_lod_remove_queue.size(),
|
|
_chunk_create_queue.size(),
|
|
_chunk_remove_queue.size(),
|
|
" ".join(timings),
|
|
])
|
|
|
|
|
|
## Read-only machine-readable state used by renderer baseline tooling.
|
|
## Keep this free of queue mutation so checkpoint capture cannot alter runtime behavior.
|
|
func render_baseline_snapshot() -> Dictionary:
|
|
return {
|
|
"tiles": _tile_states.size(),
|
|
"loading": _tile_loading_tasks.size(),
|
|
"queues": {
|
|
"tile": _tile_load_queue.size(),
|
|
"terrain_upgrade": _terrain_upgrade_tasks.size(),
|
|
"terrain_control": _terrain_control_splat_cache_tasks.size(),
|
|
"terrain_splat": _terrain_splat_queue_size(),
|
|
"water": _water_load_queue.size() + _water_load_tasks.size(),
|
|
"detail": _detail_asset_queue.size(),
|
|
"m2_task": _m2_group_tasks.size(),
|
|
"m2_animation": _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(),
|
|
"m2_mesh": _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(),
|
|
"m2_build": _m2_build_queue.size(),
|
|
"wmo_build": _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(),
|
|
"wmo_groups": _wmo_render_build_queue.size(),
|
|
"lod_create": _tile_lod_create_queue.size(),
|
|
"lod_remove": _tile_lod_remove_queue.size(),
|
|
"chunk_create": _chunk_create_queue.size(),
|
|
"chunk_remove": _chunk_remove_queue.size(),
|
|
},
|
|
"cache_entries": _tile_mesh_cache.size(),
|
|
"m2_active_unique_ids": _m2_unique_registry.size(),
|
|
"wmo_instances": _wmo_registry.size(),
|
|
}
|
|
|
|
|
|
func _tick_runtime_stats(delta: float) -> void:
|
|
if not runtime_stats_enabled or Engine.is_editor_hint():
|
|
return
|
|
|
|
_runtime_stats_accum += delta
|
|
if _runtime_stats_accum < maxf(runtime_stats_interval, 0.25):
|
|
return
|
|
_runtime_stats_accum = 0.0
|
|
|
|
var wanted_tiles := 0
|
|
var retained_tiles := 0
|
|
var chunk_nodes := 0
|
|
var chunk_rids := 0
|
|
var tile_lod_nodes := 0
|
|
var tile_lod_rids := 0
|
|
var tile_lod_hidden := 0
|
|
var tile_lod_prepared := 0
|
|
var m2_tiles := 0
|
|
var m2_multimeshes := 0
|
|
var m2_instances := 0
|
|
var terrain_baked_full := 0
|
|
var terrain_control_splat := 0
|
|
var terrain_splat := 0
|
|
|
|
for state_variant in _tile_states.values():
|
|
if not (state_variant is Dictionary):
|
|
continue
|
|
var state: Dictionary = state_variant
|
|
if bool(state.get("wanted", false)):
|
|
wanted_tiles += 1
|
|
if bool(state.get("retained", false)):
|
|
retained_tiles += 1
|
|
|
|
var chunks: Dictionary = state.get("chunks", {})
|
|
for entry_variant in chunks.values():
|
|
if not (entry_variant is Dictionary):
|
|
continue
|
|
var entry: Dictionary = entry_variant
|
|
if bool(entry.get("empty", false)):
|
|
continue
|
|
var chunk_rid: RID = entry.get("rid", RID())
|
|
if chunk_rid.is_valid():
|
|
chunk_rids += 1
|
|
elif entry.get("node", null) != null:
|
|
chunk_nodes += 1
|
|
|
|
var tile_lod_rid: RID = state.get("tile_lod_rid", RID())
|
|
if tile_lod_rid.is_valid():
|
|
tile_lod_rids += 1
|
|
elif state.get("tile_lod_node", null) != null:
|
|
tile_lod_nodes += 1
|
|
if int(state.get("tile_lod", -1)) >= 0 and not bool(state.get("tile_lod_visible", true)):
|
|
tile_lod_hidden += 1
|
|
var prepared_tile_lod_rid: RID = state.get("prepared_tile_lod_rid", RID())
|
|
if prepared_tile_lod_rid.is_valid():
|
|
tile_lod_prepared += 1
|
|
var terrain_source := String(state.get("quality_terrain_source", ""))
|
|
if terrain_source == "baked_full":
|
|
terrain_baked_full += 1
|
|
elif terrain_source == "control_splat_cache":
|
|
terrain_control_splat += 1
|
|
elif terrain_source == "splat_cache" or terrain_source == "splat":
|
|
terrain_splat += 1
|
|
|
|
var root: Node = state.get("root", null)
|
|
if root == null or not is_instance_valid(root):
|
|
continue
|
|
var m2_root := root.get_node_or_null("M2s")
|
|
if m2_root == null:
|
|
continue
|
|
m2_tiles += 1
|
|
var m2_counts := {"multimeshes": 0, "instances": 0}
|
|
_accumulate_multimesh_stats(m2_root, m2_counts)
|
|
m2_multimeshes += int(m2_counts["multimeshes"])
|
|
m2_instances += int(m2_counts["instances"])
|
|
|
|
print("PERF fps=%.1f tiles=%d wanted=%d retained=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d water=%d src[baked=%d ctl=%d splat=%d] cache=%d lod_rid=%d lod_node=%d lod_hidden=%d lod_prepared=%d chunk_rid=%d chunk_node=%d queues[tile=%d detail=%d m2_task=%d m2_anim=%d m2_mesh=%d m2_build=%d wmo_build=%d wmo_groups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] m2_tiles=%d m2_mm=%d m2_inst=%d wmo=%d" % [
|
|
Engine.get_frames_per_second(),
|
|
_tile_states.size(),
|
|
wanted_tiles,
|
|
retained_tiles,
|
|
_tile_loading_tasks.size(),
|
|
_terrain_upgrade_tasks.size(),
|
|
_terrain_control_splat_cache_tasks.size(),
|
|
_terrain_splat_queue_size(),
|
|
_water_load_queue.size() + _water_load_tasks.size(),
|
|
terrain_baked_full,
|
|
terrain_control_splat,
|
|
terrain_splat,
|
|
_tile_mesh_cache.size(),
|
|
tile_lod_rids,
|
|
tile_lod_nodes,
|
|
tile_lod_hidden,
|
|
tile_lod_prepared,
|
|
chunk_rids,
|
|
chunk_nodes,
|
|
_tile_load_queue.size(),
|
|
_detail_asset_queue.size(),
|
|
_m2_group_tasks.size(),
|
|
_m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(),
|
|
_m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(),
|
|
_m2_build_queue.size(),
|
|
_wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(),
|
|
_wmo_render_build_queue.size(),
|
|
_tile_lod_create_queue.size(),
|
|
_tile_lod_remove_queue.size(),
|
|
_chunk_create_queue.size(),
|
|
_chunk_remove_queue.size(),
|
|
m2_tiles,
|
|
m2_multimeshes,
|
|
m2_instances,
|
|
_wmo_registry.size(),
|
|
])
|
|
|
|
|
|
func _tick_terrain_quality_log(delta: float) -> void:
|
|
if not terrain_quality_log_enabled or Engine.is_editor_hint():
|
|
return
|
|
_terrain_quality_log_accum += delta
|
|
if _terrain_quality_log_accum < maxf(terrain_quality_log_interval, 0.25):
|
|
return
|
|
_terrain_quality_log_accum = 0.0
|
|
|
|
var target := 0
|
|
var ready := 0
|
|
var pending := 0
|
|
var fallback_baked := 0
|
|
var fallback_stream := 0
|
|
var hidden_wait := 0
|
|
var missing_cache := 0
|
|
var stale_or_other := 0
|
|
var focus_state_source := "none"
|
|
var focus_tile := _world_to_tile(_last_focus_pos)
|
|
|
|
for state_variant in _tile_states.values():
|
|
if not (state_variant is Dictionary):
|
|
continue
|
|
var state: Dictionary = state_variant
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
if not terrain_control_splat_quality_enabled:
|
|
continue
|
|
if not _tile_within_control_splat_terrain_radius(state, _last_focus_pos):
|
|
continue
|
|
|
|
target += 1
|
|
var key := String(state.get("key", ""))
|
|
var source := String(state.get("quality_terrain_source", ""))
|
|
var is_focus := int(state.get("tx", -9999)) == focus_tile.x and int(state.get("ty", -9999)) == focus_tile.y
|
|
if is_focus:
|
|
focus_state_source = source if not source.is_empty() else "stream"
|
|
|
|
if source == "control_splat_cache":
|
|
ready += 1
|
|
continue
|
|
if not key.is_empty() and _terrain_control_splat_cache_tasks.has(key):
|
|
pending += 1
|
|
continue
|
|
|
|
var cache_path := _get_terrain_control_splat_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0)))
|
|
if not ResourceLoader.exists(cache_path):
|
|
missing_cache += 1
|
|
continue
|
|
|
|
var desired_lod := int(state.get("desired_tile_lod", -1))
|
|
if desired_lod < 0:
|
|
hidden_wait += 1
|
|
elif source == "baked_full":
|
|
fallback_baked += 1
|
|
else:
|
|
var tile_lod := int(state.get("tile_lod", -1))
|
|
if tile_lod >= 0:
|
|
fallback_stream += 1
|
|
else:
|
|
stale_or_other += 1
|
|
|
|
var done := ready >= target and pending == 0 and missing_cache == 0 and hidden_wait == 0 and fallback_baked == 0 and fallback_stream == 0
|
|
print("TERRAIN_QUALITY target=%d ready=%d pending=%d fallback[baked=%d stream=%d hidden=%d other=%d] missing_cache=%d queue=%d focus=%d_%d:%s done=%s" % [
|
|
target,
|
|
ready,
|
|
pending,
|
|
fallback_baked,
|
|
fallback_stream,
|
|
hidden_wait,
|
|
stale_or_other,
|
|
missing_cache,
|
|
_terrain_control_splat_cache_tasks.size(),
|
|
focus_tile.x,
|
|
focus_tile.y,
|
|
focus_state_source,
|
|
str(done),
|
|
])
|
|
|
|
|
|
func _accumulate_multimesh_stats(node: Node, counts: Dictionary) -> void:
|
|
if node is MultiMeshInstance3D:
|
|
var mmi := node as MultiMeshInstance3D
|
|
if mmi.multimesh:
|
|
counts["multimeshes"] = int(counts["multimeshes"]) + 1
|
|
counts["instances"] = int(counts["instances"]) + mmi.multimesh.instance_count
|
|
for child in node.get_children():
|
|
_accumulate_multimesh_stats(child, counts)
|
|
|
|
|
|
func _start_tile_load_async(request: Dictionary) -> void:
|
|
var key: String = request["key"]
|
|
if _tile_loading_tasks.has(key):
|
|
return
|
|
|
|
var stream_res_path := _get_streaming_tile_resource_path(int(request["tx"]), int(request["ty"]))
|
|
if _can_use_baked_tile_cache() and ResourceLoader.exists(stream_res_path):
|
|
var err := ResourceLoader.load_threaded_request(
|
|
stream_res_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_REUSE)
|
|
if err == OK:
|
|
_tile_loading_tasks[key] = {
|
|
"mode": "stream",
|
|
"path": stream_res_path,
|
|
"request": request,
|
|
}
|
|
return
|
|
|
|
var baked_res_path := _get_baked_tile_resource_path(int(request["tx"]), int(request["ty"]))
|
|
if _can_use_baked_tile_cache() and ResourceLoader.exists(baked_res_path):
|
|
var err := ResourceLoader.load_threaded_request(
|
|
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 = []
|
|
var finalize_budget: int = maxi(1, tile_finalize_ops_per_tick)
|
|
_tile_result_mutex.lock()
|
|
while finalize_budget > 0 and not _tile_result_queue.is_empty():
|
|
results.append(_tile_result_queue.pop_front())
|
|
finalize_budget -= 1
|
|
_tile_result_mutex.unlock()
|
|
|
|
for result in results:
|
|
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 = []
|
|
if finalize_budget <= 0:
|
|
return
|
|
for key in _tile_loading_tasks.keys():
|
|
if baked_ready.size() >= finalize_budget:
|
|
break
|
|
var pending: Dictionary = _tile_loading_tasks[key]
|
|
if pending.get("mode", "raw") != "baked" and pending.get("mode", "raw") != "stream":
|
|
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 loaded_tile: Resource = ResourceLoader.load_threaded_get(pending["path"])
|
|
if not _should_accept_loaded_tile(request):
|
|
continue
|
|
if pending.get("mode", "raw") == "stream":
|
|
if loaded_tile == null or loaded_tile.get_script() != STREAMING_TILE_SCRIPT:
|
|
push_warning("Invalid streaming tile resource: %s" % pending["path"])
|
|
_start_raw_tile_load_async(request)
|
|
continue
|
|
_finalize_loaded_streaming_tile(request, loaded_tile)
|
|
continue
|
|
if loaded_tile == null or loaded_tile.get_script() != BAKED_TILE_SCRIPT:
|
|
push_warning("Invalid baked tile resource: %s" % pending["path"])
|
|
_start_raw_tile_load_async(request)
|
|
continue
|
|
|
|
_finalize_loaded_baked_tile(request, loaded_tile)
|
|
|
|
|
|
func _sync_terrain_quality(state: Dictionary, focus_pos: Vector3) -> Dictionary:
|
|
state = _drop_splat_quality_if_inactive(state, focus_pos)
|
|
if not terrain_full_quality_enabled:
|
|
return state
|
|
if state.get("stream_tile", null) == null:
|
|
return state
|
|
if not bool(state.get("wanted", false)):
|
|
return state
|
|
if not _tile_within_full_terrain_radius(state, focus_pos):
|
|
return state
|
|
|
|
var key := String(state.get("key", ""))
|
|
var quality_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
|
var quality_source := String(state.get("quality_terrain_source", ""))
|
|
var control_splat_requested := false
|
|
if terrain_control_splat_quality_enabled and _tile_within_control_splat_terrain_radius(state, focus_pos):
|
|
if quality_source == "control_splat_cache":
|
|
return state
|
|
if key.is_empty():
|
|
return state
|
|
if not _can_activate_terrain_control_splat(key, state, focus_pos):
|
|
if quality_mesh != null:
|
|
return state
|
|
return state
|
|
if terrain_control_splat_primary and _tile_is_focus_tile(state, focus_pos):
|
|
var immediate_state := _load_terrain_control_splat_immediate(state, focus_pos)
|
|
if String(immediate_state.get("quality_terrain_source", "")) == "control_splat_cache":
|
|
return immediate_state
|
|
control_splat_requested = _request_terrain_control_splat_cache(state)
|
|
if control_splat_requested:
|
|
return state
|
|
|
|
if state.get("baked_tile", null) != null:
|
|
return state
|
|
|
|
if terrain_splat_quality_enabled and _tile_within_splat_terrain_radius(state, focus_pos):
|
|
if quality_source == "splat_cache" or quality_source == "splat":
|
|
return state
|
|
if key.is_empty():
|
|
return state
|
|
if not _can_activate_terrain_splat(key, state, focus_pos):
|
|
if quality_mesh != null:
|
|
return state
|
|
return state
|
|
if _request_terrain_splat_cache(state):
|
|
return state
|
|
if terrain_splat_runtime_build_enabled:
|
|
if _terrain_splat_tasks.has(key):
|
|
return state
|
|
if _terrain_splat_tasks.size() >= maxi(1, max_concurrent_terrain_splat_tasks):
|
|
return state
|
|
var task_id: int = WorkerThreadPool.add_task(_load_terrain_splat_task.bind(key, String(state.get("path", ""))))
|
|
_terrain_splat_tasks[key] = task_id
|
|
return state
|
|
|
|
if quality_mesh != null:
|
|
return state
|
|
if terrain_control_splat_primary and control_splat_requested:
|
|
return state
|
|
if terrain_control_splat_primary and terrain_control_splat_quality_enabled and _tile_within_control_splat_terrain_radius(state, focus_pos):
|
|
var control_path := _get_terrain_control_splat_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0)))
|
|
if ResourceLoader.exists(control_path):
|
|
return state
|
|
|
|
if key.is_empty() or _terrain_upgrade_tasks.has(key):
|
|
return state
|
|
if _terrain_upgrade_tasks.size() >= maxi(1, max_concurrent_terrain_upgrade_tasks):
|
|
return state
|
|
|
|
var baked_path := _get_baked_tile_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0)))
|
|
if not ResourceLoader.exists(baked_path):
|
|
return state
|
|
|
|
var err := ResourceLoader.load_threaded_request(
|
|
baked_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_IGNORE)
|
|
if err == OK or err == ERR_BUSY:
|
|
_terrain_upgrade_tasks[key] = {
|
|
"path": baked_path,
|
|
"tx": int(state.get("tx", 0)),
|
|
"ty": int(state.get("ty", 0)),
|
|
}
|
|
return state
|
|
|
|
|
|
func _drop_splat_quality_if_inactive(state: Dictionary, focus_pos: Vector3) -> Dictionary:
|
|
var quality_source := String(state.get("quality_terrain_source", ""))
|
|
if quality_source == "control_splat_cache":
|
|
if bool(state.get("wanted", false)) and _tile_within_control_splat_terrain_radius(state, focus_pos):
|
|
return state
|
|
return _drop_splat_quality(state)
|
|
if quality_source != "splat_cache" and quality_source != "splat":
|
|
return state
|
|
if bool(state.get("wanted", false)) and _tile_within_splat_terrain_radius(state, focus_pos):
|
|
return state
|
|
|
|
return _drop_splat_quality(state)
|
|
|
|
|
|
func _drop_splat_quality(state: Dictionary) -> Dictionary:
|
|
state.erase("quality_terrain_mesh")
|
|
state.erase("quality_terrain_source")
|
|
var key := String(state.get("key", ""))
|
|
if not key.is_empty():
|
|
state = _restore_quality_terrain_mesh(key, state)
|
|
_remove_tile_mesh_cache_entries(key)
|
|
return state
|
|
|
|
|
|
func _can_activate_terrain_splat(key: String, state: Dictionary, focus_pos: Vector3) -> bool:
|
|
var limit := maxi(0, max_active_terrain_splat_tiles)
|
|
if limit <= 0:
|
|
return false
|
|
if _tile_is_focus_tile(state, focus_pos):
|
|
return true
|
|
if _terrain_splat_cache_tasks.has(key) or _terrain_splat_tasks.has(key):
|
|
return true
|
|
return _active_terrain_splat_count(focus_pos) < limit
|
|
|
|
|
|
func _can_activate_terrain_control_splat(key: String, state: Dictionary, focus_pos: Vector3) -> bool:
|
|
var limit := maxi(0, max_active_terrain_control_splat_tiles)
|
|
if limit <= 0:
|
|
return false
|
|
if _tile_is_focus_tile(state, focus_pos):
|
|
return true
|
|
if _terrain_control_splat_cache_tasks.has(key):
|
|
return true
|
|
return _active_terrain_control_splat_count(focus_pos) < limit
|
|
|
|
|
|
func _active_terrain_control_splat_count(focus_pos: Vector3) -> int:
|
|
var count := _terrain_control_splat_cache_tasks.size()
|
|
for state_variant in _tile_states.values():
|
|
if not (state_variant is Dictionary):
|
|
continue
|
|
var state: Dictionary = state_variant
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
if String(state.get("quality_terrain_source", "")) != "control_splat_cache":
|
|
continue
|
|
if _tile_within_control_splat_terrain_radius(state, focus_pos):
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func _active_terrain_splat_count(focus_pos: Vector3) -> int:
|
|
var count := _terrain_splat_cache_tasks.size() + _terrain_splat_tasks.size()
|
|
for state_variant in _tile_states.values():
|
|
if not (state_variant is Dictionary):
|
|
continue
|
|
var state: Dictionary = state_variant
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
var source := String(state.get("quality_terrain_source", ""))
|
|
if source != "splat_cache" and source != "splat":
|
|
continue
|
|
if _tile_within_splat_terrain_radius(state, focus_pos):
|
|
count += 1
|
|
return count
|
|
|
|
|
|
func _prune_active_terrain_control_splat_tiles(focus_pos: Vector3) -> void:
|
|
var limit := maxi(0, max_active_terrain_control_splat_tiles)
|
|
if limit <= 0:
|
|
for key_variant in _tile_states.keys():
|
|
var key := String(key_variant)
|
|
var state: Dictionary = _tile_states[key]
|
|
if String(state.get("quality_terrain_source", "")) == "control_splat_cache":
|
|
state = _drop_splat_quality(state)
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
_tile_states[key] = state
|
|
return
|
|
|
|
var active: Array = []
|
|
for key_variant in _tile_states.keys():
|
|
var key := String(key_variant)
|
|
var state: Dictionary = _tile_states[key]
|
|
if String(state.get("quality_terrain_source", "")) != "control_splat_cache":
|
|
continue
|
|
if not bool(state.get("wanted", false)) or not _tile_within_control_splat_terrain_radius(state, focus_pos):
|
|
state = _drop_splat_quality(state)
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
_tile_states[key] = state
|
|
continue
|
|
active.append({
|
|
"key": key,
|
|
"dist_sq": _tile_dist_sq(state, focus_pos),
|
|
"focus": _tile_is_focus_tile(state, focus_pos),
|
|
})
|
|
|
|
if active.size() <= limit:
|
|
return
|
|
|
|
active.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
if bool(a["focus"]) != bool(b["focus"]):
|
|
return bool(a["focus"])
|
|
return float(a["dist_sq"]) < float(b["dist_sq"]))
|
|
|
|
var keep := {}
|
|
for i in range(mini(limit, active.size())):
|
|
keep[String(active[i]["key"])] = true
|
|
|
|
for row in active:
|
|
var key := String(row["key"])
|
|
if keep.has(key) or not _tile_states.has(key):
|
|
continue
|
|
var state: Dictionary = _tile_states[key]
|
|
state = _drop_splat_quality(state)
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
_tile_states[key] = state
|
|
|
|
|
|
func _prune_active_terrain_splat_tiles(focus_pos: Vector3) -> void:
|
|
var limit := maxi(0, max_active_terrain_splat_tiles)
|
|
if limit <= 0:
|
|
for key_variant in _tile_states.keys():
|
|
var key := String(key_variant)
|
|
var state: Dictionary = _tile_states[key]
|
|
var source := String(state.get("quality_terrain_source", ""))
|
|
if source == "splat_cache" or source == "splat":
|
|
state = _drop_splat_quality(state)
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
_tile_states[key] = state
|
|
return
|
|
|
|
var active: Array = []
|
|
for key_variant in _tile_states.keys():
|
|
var key := String(key_variant)
|
|
var state: Dictionary = _tile_states[key]
|
|
var source := String(state.get("quality_terrain_source", ""))
|
|
if source != "splat_cache" and source != "splat":
|
|
continue
|
|
if not bool(state.get("wanted", false)) or not _tile_within_splat_terrain_radius(state, focus_pos):
|
|
state = _drop_splat_quality(state)
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
_tile_states[key] = state
|
|
continue
|
|
active.append({
|
|
"key": key,
|
|
"dist_sq": _tile_dist_sq(state, focus_pos),
|
|
"focus": _tile_is_focus_tile(state, focus_pos),
|
|
})
|
|
|
|
if active.size() <= limit:
|
|
return
|
|
|
|
active.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
|
if bool(a["focus"]) != bool(b["focus"]):
|
|
return bool(a["focus"])
|
|
return float(a["dist_sq"]) < float(b["dist_sq"]))
|
|
|
|
var keep := {}
|
|
for i in range(mini(limit, active.size())):
|
|
keep[String(active[i]["key"])] = true
|
|
|
|
for row in active:
|
|
var key := String(row["key"])
|
|
if keep.has(key):
|
|
continue
|
|
if not _tile_states.has(key):
|
|
continue
|
|
var state: Dictionary = _tile_states[key]
|
|
state = _drop_splat_quality(state)
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
_tile_states[key] = state
|
|
|
|
|
|
func _tile_is_focus_tile(state: Dictionary, focus_pos: Vector3) -> bool:
|
|
var focus_tile := _world_to_tile(focus_pos)
|
|
return int(state.get("tx", -9999)) == focus_tile.x and int(state.get("ty", -9999)) == focus_tile.y
|
|
|
|
|
|
func _request_terrain_control_splat_cache(state: Dictionary) -> bool:
|
|
var key := String(state.get("key", ""))
|
|
if key.is_empty():
|
|
return false
|
|
if _terrain_control_splat_cache_tasks.has(key):
|
|
return true
|
|
|
|
var cache_path := _get_terrain_control_splat_resource_path(
|
|
int(state.get("tx", 0)),
|
|
int(state.get("ty", 0)))
|
|
if not ResourceLoader.exists(cache_path):
|
|
return false
|
|
|
|
var err := ResourceLoader.load_threaded_request(
|
|
cache_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_IGNORE)
|
|
if err == OK or err == ERR_BUSY:
|
|
_terrain_control_splat_cache_tasks[key] = {
|
|
"path": cache_path,
|
|
"tx": int(state.get("tx", 0)),
|
|
"ty": int(state.get("ty", 0)),
|
|
}
|
|
return true
|
|
return false
|
|
|
|
|
|
func _load_terrain_control_splat_immediate(state: Dictionary, focus_pos: Vector3) -> Dictionary:
|
|
var key := String(state.get("key", ""))
|
|
if key.is_empty():
|
|
return state
|
|
var cache_path := _get_terrain_control_splat_resource_path(
|
|
int(state.get("tx", 0)),
|
|
int(state.get("ty", 0)))
|
|
if not ResourceLoader.exists(cache_path):
|
|
return state
|
|
|
|
var control_tile: Resource = ResourceLoader.load(cache_path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
if control_tile == null or control_tile.get_script() != CONTROL_SPLAT_TILE_SCRIPT:
|
|
push_warning("Invalid immediate control splat tile resource: %s" % cache_path)
|
|
return state
|
|
if int(control_tile.get("format_version")) < REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION:
|
|
push_warning("Outdated immediate control splat tile resource: %s" % cache_path)
|
|
return state
|
|
|
|
var mesh: Mesh = control_tile.get("terrain_mesh")
|
|
if mesh == null:
|
|
push_warning("Immediate control splat tile has no terrain mesh: %s" % cache_path)
|
|
return state
|
|
mesh = mesh.duplicate(true)
|
|
if _builder.has_method("apply_control_splat_material"):
|
|
mesh = _builder.apply_control_splat_material(
|
|
mesh,
|
|
control_tile.get("texture_images"),
|
|
control_tile.get("alpha_atlas"),
|
|
control_tile.get("layer_index_map"))
|
|
if mesh == null or mesh.surface_get_material(0) == null:
|
|
push_warning("Immediate control splat material failed: %s" % cache_path)
|
|
return state
|
|
|
|
state["quality_terrain_mesh"] = mesh
|
|
state["quality_terrain_source"] = "control_splat_cache"
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, focus_pos)
|
|
_terrain_control_splat_cache_tasks.erase(key)
|
|
var cache_key := _tile_mesh_cache_key(key, 0)
|
|
_tile_mesh_cache.erase(cache_key)
|
|
_tile_mesh_cache_order.erase(cache_key)
|
|
return state
|
|
|
|
|
|
func _request_terrain_splat_cache(state: Dictionary) -> bool:
|
|
var key := String(state.get("key", ""))
|
|
if key.is_empty():
|
|
return false
|
|
if _terrain_splat_cache_tasks.has(key):
|
|
return true
|
|
|
|
var cache_path := _get_terrain_splat_resource_path(
|
|
int(state.get("tx", 0)),
|
|
int(state.get("ty", 0)))
|
|
if not ResourceLoader.exists(cache_path):
|
|
return false
|
|
|
|
var err := ResourceLoader.load_threaded_request(
|
|
cache_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_IGNORE)
|
|
if err == OK or err == ERR_BUSY:
|
|
_terrain_splat_cache_tasks[key] = {
|
|
"path": cache_path,
|
|
"tx": int(state.get("tx", 0)),
|
|
"ty": int(state.get("ty", 0)),
|
|
}
|
|
return true
|
|
return false
|
|
|
|
|
|
func _drain_terrain_upgrade_results() -> void:
|
|
var ready: Array[String] = []
|
|
var budget := maxi(0, terrain_upgrade_finalize_ops_per_tick)
|
|
if budget <= 0:
|
|
return
|
|
|
|
for key_variant in _terrain_upgrade_tasks.keys():
|
|
if ready.size() >= budget:
|
|
break
|
|
var key := String(key_variant)
|
|
var pending: Dictionary = _terrain_upgrade_tasks[key]
|
|
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
|
|
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
|
|
ready.append(key)
|
|
|
|
var changed := false
|
|
for key in ready:
|
|
if not _terrain_upgrade_tasks.has(key):
|
|
continue
|
|
var pending: Dictionary = _terrain_upgrade_tasks[key]
|
|
_terrain_upgrade_tasks.erase(key)
|
|
var path := String(pending.get("path", ""))
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED:
|
|
continue
|
|
var baked_tile: Resource = ResourceLoader.load_threaded_get(path)
|
|
if baked_tile == null or baked_tile.get_script() != BAKED_TILE_SCRIPT:
|
|
continue
|
|
if not _tile_states.has(key):
|
|
continue
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
if state.get("stream_tile", null) == null:
|
|
continue
|
|
if String(state.get("quality_terrain_source", "")) == "control_splat_cache":
|
|
continue
|
|
var baked_version: int = int(baked_tile.get("format_version"))
|
|
if baked_version < REQUIRED_BAKED_TILE_FORMAT_VERSION:
|
|
continue
|
|
state["baked_tile"] = baked_tile
|
|
var full_mesh: Mesh = baked_tile.get("full_mesh")
|
|
if full_mesh != null:
|
|
full_mesh = full_mesh.duplicate(true)
|
|
if _builder.has_method("upgrade_cached_baked_mesh_materials"):
|
|
full_mesh = _builder.upgrade_cached_baked_mesh_materials(full_mesh, 1.0, -1.25, 0.35)
|
|
state["quality_terrain_mesh"] = full_mesh
|
|
state["quality_terrain_source"] = "baked_full"
|
|
_store_quality_terrain_mesh(key, full_mesh, "baked_full")
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
_tile_mesh_cache.erase(_tile_mesh_cache_key(key, 0))
|
|
_tile_mesh_cache_order.erase(_tile_mesh_cache_key(key, 0))
|
|
changed = true
|
|
|
|
if changed:
|
|
_rebuild_chunk_queues()
|
|
|
|
|
|
func _drain_terrain_control_splat_cache_results() -> void:
|
|
var ready: Array[String] = []
|
|
var budget := maxi(0, terrain_control_splat_cache_finalize_ops_per_tick)
|
|
if budget <= 0:
|
|
return
|
|
|
|
for key_variant in _terrain_control_splat_cache_tasks.keys():
|
|
if ready.size() >= budget:
|
|
break
|
|
var key := String(key_variant)
|
|
var pending: Dictionary = _terrain_control_splat_cache_tasks[key]
|
|
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
|
|
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
|
|
ready.append(key)
|
|
|
|
var changed := false
|
|
for key in ready:
|
|
if not _terrain_control_splat_cache_tasks.has(key):
|
|
continue
|
|
var pending: Dictionary = _terrain_control_splat_cache_tasks[key]
|
|
_terrain_control_splat_cache_tasks.erase(key)
|
|
var path := String(pending.get("path", ""))
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED:
|
|
continue
|
|
var control_tile: Resource = ResourceLoader.load_threaded_get(path)
|
|
if control_tile == null or control_tile.get_script() != CONTROL_SPLAT_TILE_SCRIPT:
|
|
continue
|
|
if int(control_tile.get("format_version")) < REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION:
|
|
continue
|
|
if not _tile_states.has(key):
|
|
continue
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
if not _tile_within_control_splat_terrain_radius(state, _last_focus_pos):
|
|
continue
|
|
if not _can_activate_terrain_control_splat(key, state, _last_focus_pos):
|
|
continue
|
|
|
|
var mesh: Mesh = control_tile.get("terrain_mesh")
|
|
if mesh == null:
|
|
continue
|
|
mesh = mesh.duplicate(true)
|
|
if _builder.has_method("apply_control_splat_material"):
|
|
mesh = _builder.apply_control_splat_material(
|
|
mesh,
|
|
control_tile.get("texture_images"),
|
|
control_tile.get("alpha_atlas"),
|
|
control_tile.get("layer_index_map"))
|
|
|
|
state["quality_terrain_mesh"] = mesh
|
|
state["quality_terrain_source"] = "control_splat_cache"
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
var cache_key := _tile_mesh_cache_key(key, 0)
|
|
_tile_mesh_cache.erase(cache_key)
|
|
_tile_mesh_cache_order.erase(cache_key)
|
|
changed = true
|
|
|
|
if changed:
|
|
_rebuild_chunk_queues()
|
|
|
|
|
|
func _drain_terrain_splat_cache_results() -> void:
|
|
var ready: Array[String] = []
|
|
var budget := maxi(0, terrain_splat_cache_finalize_ops_per_tick)
|
|
if budget <= 0:
|
|
return
|
|
|
|
for key_variant in _terrain_splat_cache_tasks.keys():
|
|
if ready.size() >= budget:
|
|
break
|
|
var key := String(key_variant)
|
|
var pending: Dictionary = _terrain_splat_cache_tasks[key]
|
|
var status := ResourceLoader.load_threaded_get_status(String(pending.get("path", "")))
|
|
if status == ResourceLoader.THREAD_LOAD_LOADED or status == ResourceLoader.THREAD_LOAD_FAILED:
|
|
ready.append(key)
|
|
|
|
var changed := false
|
|
for key in ready:
|
|
if not _terrain_splat_cache_tasks.has(key):
|
|
continue
|
|
var pending: Dictionary = _terrain_splat_cache_tasks[key]
|
|
_terrain_splat_cache_tasks.erase(key)
|
|
var path := String(pending.get("path", ""))
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED:
|
|
continue
|
|
var splat_tile: Resource = ResourceLoader.load_threaded_get(path)
|
|
if splat_tile == null or splat_tile.get_script() != SPLAT_TILE_SCRIPT:
|
|
continue
|
|
if int(splat_tile.get("format_version")) < REQUIRED_SPLAT_TILE_FORMAT_VERSION:
|
|
continue
|
|
if not _tile_states.has(key):
|
|
continue
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
if not _tile_within_splat_terrain_radius(state, _last_focus_pos):
|
|
continue
|
|
if not _can_activate_terrain_splat(key, state, _last_focus_pos):
|
|
continue
|
|
|
|
var mesh: Mesh = splat_tile.get("terrain_mesh")
|
|
if mesh == null:
|
|
continue
|
|
|
|
state["quality_terrain_mesh"] = mesh
|
|
state["quality_terrain_source"] = "splat_cache"
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
var cache_key := _tile_mesh_cache_key(key, 0)
|
|
_tile_mesh_cache.erase(cache_key)
|
|
_tile_mesh_cache_order.erase(cache_key)
|
|
changed = true
|
|
|
|
if changed:
|
|
_rebuild_chunk_queues()
|
|
|
|
|
|
func _build_high_quality_stream_terrain_mesh(stream_mesh: Mesh, full_mesh: Mesh) -> Mesh:
|
|
if stream_mesh == null or full_mesh == null:
|
|
return null
|
|
var upgraded := stream_mesh.duplicate(true) as ArrayMesh
|
|
if upgraded == null:
|
|
return null
|
|
var surface_count := mini(upgraded.get_surface_count(), full_mesh.get_surface_count())
|
|
for surface_idx in surface_count:
|
|
var material := full_mesh.surface_get_material(surface_idx)
|
|
if material != null:
|
|
upgraded.surface_set_material(surface_idx, material)
|
|
return upgraded
|
|
|
|
|
|
func _load_terrain_splat_task(key: String, adt_path: String) -> void:
|
|
var data: Dictionary = {}
|
|
if not adt_path.is_empty():
|
|
var loader = ClassDB.instantiate("ADTLoader")
|
|
if loader:
|
|
data = loader.call("load_adt", adt_path)
|
|
|
|
_terrain_splat_result_mutex.lock()
|
|
_terrain_splat_result_queue.append({
|
|
"key": key,
|
|
"data": data,
|
|
})
|
|
_terrain_splat_result_mutex.unlock()
|
|
|
|
|
|
func _drain_terrain_splat_results() -> void:
|
|
var results: Array = []
|
|
var budget := maxi(0, terrain_splat_builds_per_tick)
|
|
if budget <= 0:
|
|
return
|
|
|
|
_terrain_splat_result_mutex.lock()
|
|
while budget > 0 and not _terrain_splat_result_queue.is_empty():
|
|
results.append(_terrain_splat_result_queue.pop_front())
|
|
budget -= 1
|
|
_terrain_splat_result_mutex.unlock()
|
|
|
|
var changed := false
|
|
for result_variant in results:
|
|
if not (result_variant is Dictionary):
|
|
continue
|
|
var result: Dictionary = result_variant
|
|
var key := String(result.get("key", ""))
|
|
if _terrain_splat_tasks.has(key):
|
|
WorkerThreadPool.wait_for_task_completion(int(_terrain_splat_tasks[key]))
|
|
_terrain_splat_tasks.erase(key)
|
|
if not _tile_states.has(key):
|
|
continue
|
|
var state: Dictionary = _tile_states[key]
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
if not _tile_within_splat_terrain_radius(state, _last_focus_pos):
|
|
continue
|
|
if not _can_activate_terrain_splat(key, state, _last_focus_pos):
|
|
continue
|
|
|
|
var data: Dictionary = result.get("data", {})
|
|
if data.is_empty():
|
|
continue
|
|
var payload: Dictionary = _builder.build_tile_coarse_render_payload(
|
|
data,
|
|
_shared_tex_cache,
|
|
ProjectSettings.globalize_path(extracted_dir),
|
|
maxi(0, terrain_splat_lod))
|
|
var mesh: Mesh = payload.get("mesh", null)
|
|
if mesh == null:
|
|
continue
|
|
|
|
state["quality_terrain_mesh"] = mesh
|
|
state["quality_terrain_source"] = "splat"
|
|
state["data"] = data
|
|
state["tex_names"] = data.get("textures", PackedStringArray())
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
var cache_key := _tile_mesh_cache_key(key, 0)
|
|
_tile_mesh_cache.erase(cache_key)
|
|
_tile_mesh_cache_order.erase(cache_key)
|
|
changed = true
|
|
|
|
if changed:
|
|
_rebuild_chunk_queues()
|
|
|
|
|
|
func _request_tile_water_load(state: Dictionary) -> void:
|
|
if not enable_water:
|
|
return
|
|
if bool(state.get("water_loaded", false)):
|
|
return
|
|
var key := String(state.get("key", ""))
|
|
var path := String(state.get("path", ""))
|
|
if key.is_empty() or path.is_empty():
|
|
return
|
|
if _water_load_tasks.has(key) or _water_load_queued.has(key):
|
|
return
|
|
_water_load_queue.append({
|
|
"key": key,
|
|
"path": path,
|
|
})
|
|
_water_load_queued[key] = true
|
|
|
|
|
|
func _process_water_load_queue() -> void:
|
|
if not enable_water:
|
|
return
|
|
var limit := maxi(1, max_concurrent_water_tasks)
|
|
while _water_load_tasks.size() < limit and not _water_load_queue.is_empty():
|
|
var request: Dictionary = _water_load_queue.pop_front()
|
|
var key := String(request.get("key", ""))
|
|
_water_load_queued.erase(key)
|
|
if key.is_empty() or _water_load_tasks.has(key):
|
|
continue
|
|
if not _tile_states.has(key):
|
|
continue
|
|
var path := String(request.get("path", ""))
|
|
if path.is_empty():
|
|
continue
|
|
var task_id: int = WorkerThreadPool.add_task(_load_tile_water_task.bind(key, path))
|
|
_water_load_tasks[key] = task_id
|
|
|
|
|
|
func _load_tile_water_task(key: String, adt_path: String) -> void:
|
|
var data: Dictionary = {}
|
|
var loader = ClassDB.instantiate("ADTLoader")
|
|
if loader:
|
|
data = loader.call("load_adt", adt_path)
|
|
|
|
_water_result_mutex.lock()
|
|
_water_result_queue.append({
|
|
"key": key,
|
|
"path": adt_path,
|
|
"data": data,
|
|
})
|
|
_water_result_mutex.unlock()
|
|
|
|
|
|
func _drain_water_load_results() -> void:
|
|
var results: Array = []
|
|
var budget := maxi(0, water_finalize_ops_per_tick)
|
|
if budget <= 0:
|
|
return
|
|
|
|
_water_result_mutex.lock()
|
|
while budget > 0 and not _water_result_queue.is_empty():
|
|
results.append(_water_result_queue.pop_front())
|
|
budget -= 1
|
|
_water_result_mutex.unlock()
|
|
|
|
for result in results:
|
|
var key := String(result.get("key", ""))
|
|
if key.is_empty():
|
|
continue
|
|
if not _water_load_tasks.has(key):
|
|
continue
|
|
WorkerThreadPool.wait_for_task_completion(int(_water_load_tasks[key]))
|
|
_water_load_tasks.erase(key)
|
|
if _shutting_down or not _tile_states.has(key):
|
|
continue
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
if String(state.get("path", "")) != String(result.get("path", "")):
|
|
continue
|
|
var tile_root: Node = state.get("root", null)
|
|
if tile_root == null or not is_instance_valid(tile_root):
|
|
continue
|
|
|
|
var data: Dictionary = result.get("data", {})
|
|
if not data.is_empty():
|
|
var origin: Vector3 = state.get("origin", Vector3.ZERO)
|
|
var water_root: Node3D = _builder.build_tile_water_scene(data, origin)
|
|
if water_root:
|
|
tile_root.add_child(water_root)
|
|
_set_editor_owner_recursive(water_root)
|
|
|
|
state["water_loaded"] = true
|
|
_tile_states[key] = state
|
|
|
|
|
|
func _wait_for_tile_tasks() -> void:
|
|
for pending in _tile_loading_tasks.values():
|
|
if pending.get("mode", "raw") == "raw":
|
|
WorkerThreadPool.wait_for_task_completion(int(pending["task_id"]))
|
|
elif pending.get("mode", "raw") == "baked" or pending.get("mode", "raw") == "stream":
|
|
var path: String = String(pending.get("path", ""))
|
|
if not path.is_empty():
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_tile_loading_tasks.clear()
|
|
|
|
for task_id in _m2_group_tasks.values():
|
|
WorkerThreadPool.wait_for_task_completion(int(task_id))
|
|
_m2_group_tasks.clear()
|
|
|
|
_tile_result_mutex.lock()
|
|
_tile_result_queue.clear()
|
|
_tile_result_mutex.unlock()
|
|
|
|
_m2_group_result_mutex.lock()
|
|
_m2_group_result_queue.clear()
|
|
_m2_group_result_mutex.unlock()
|
|
|
|
for pending in _m2_mesh_load_requests.values():
|
|
var path: String = String(pending.get("path", ""))
|
|
if path.is_empty():
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_m2_mesh_load_requests.clear()
|
|
_m2_mesh_finalize_queue.clear()
|
|
_m2_runtime_rebuild_required_cache.clear()
|
|
|
|
for pending in _m2_animation_load_requests.values():
|
|
var path: String = String(pending.get("path", ""))
|
|
if path.is_empty():
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_m2_animation_load_requests.clear()
|
|
_m2_animation_finalize_queue.clear()
|
|
|
|
for path_variant in _wmo_scene_load_requests.values():
|
|
var path := String(path_variant)
|
|
if path.is_empty():
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_wmo_scene_load_requests.clear()
|
|
|
|
for path_variant in _wmo_render_load_requests.values():
|
|
var path := String(path_variant)
|
|
if path.is_empty():
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_wmo_render_load_requests.clear()
|
|
_wmo_render_missing_cache.clear()
|
|
_wmo_scene_cache_missing.clear()
|
|
_wmo_render_build_jobs.clear()
|
|
_wmo_render_build_queue.clear()
|
|
|
|
for pending in _terrain_upgrade_tasks.values():
|
|
var path := String(pending.get("path", ""))
|
|
if path.is_empty():
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_terrain_upgrade_tasks.clear()
|
|
|
|
for pending in _terrain_control_splat_cache_tasks.values():
|
|
if not (pending is Dictionary):
|
|
continue
|
|
var path := String(pending.get("path", ""))
|
|
if path.is_empty():
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_terrain_control_splat_cache_tasks.clear()
|
|
|
|
for task_id in _terrain_splat_tasks.values():
|
|
WorkerThreadPool.wait_for_task_completion(int(task_id))
|
|
_terrain_splat_tasks.clear()
|
|
for pending in _terrain_splat_cache_tasks.values():
|
|
if not (pending is Dictionary):
|
|
continue
|
|
var path := String(pending.get("path", ""))
|
|
if path.is_empty():
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status == ResourceLoader.THREAD_LOAD_IN_PROGRESS or status == ResourceLoader.THREAD_LOAD_LOADED:
|
|
ResourceLoader.load_threaded_get(path)
|
|
_terrain_splat_cache_tasks.clear()
|
|
_terrain_splat_result_mutex.lock()
|
|
_terrain_splat_result_queue.clear()
|
|
_terrain_splat_result_mutex.unlock()
|
|
|
|
for task_id in _water_load_tasks.values():
|
|
WorkerThreadPool.wait_for_task_completion(int(task_id))
|
|
_water_load_tasks.clear()
|
|
_water_load_queue.clear()
|
|
_water_load_queued.clear()
|
|
_water_result_mutex.lock()
|
|
_water_result_queue.clear()
|
|
_water_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:
|
|
radius = _runtime_load_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_placeholders:
|
|
_build_tile_m2_placeholders(
|
|
tile_root,
|
|
tile_origin,
|
|
data.get("m2_names", PackedStringArray()),
|
|
data.get("m2_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,
|
|
"tile_lod_visible": false,
|
|
"prepared_tile_lod": -1,
|
|
"prepared_tile_lod_rid": RID(),
|
|
"prepared_tile_lod_mesh": null,
|
|
"desired_tile_lod": -1,
|
|
"desired_tile_lod_visible": false,
|
|
"wanted": true,
|
|
"retained": true,
|
|
"root": tile_root,
|
|
"water_loaded": true,
|
|
"m2_names": data.get("m2_names", PackedStringArray()),
|
|
"m2_placements": data.get("m2_placements", []),
|
|
"m2_built": false,
|
|
"m2_unique_keys": [],
|
|
"m2_skipped_unique_keys": [],
|
|
"wmo_names": data.get("wmo_names", PackedStringArray()),
|
|
"wmo_placements": data.get("wmo_placements", []),
|
|
"wmo_refs": [],
|
|
}
|
|
|
|
# Use _last_focus_pos — already set correctly for both editor and game.
|
|
state["desired_lods"] = _compute_desired_lods(state, _last_focus_pos)
|
|
state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, _last_focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
state = _sync_terrain_quality(state, _last_focus_pos)
|
|
state = _sync_detail_assets(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
|
|
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 []
|
|
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 []
|
|
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,
|
|
"tile_lod_visible": false,
|
|
"prepared_tile_lod": -1,
|
|
"prepared_tile_lod_rid": RID(),
|
|
"prepared_tile_lod_mesh": null,
|
|
"desired_tile_lod": -1,
|
|
"desired_tile_lod_visible": false,
|
|
"wanted": true,
|
|
"retained": true,
|
|
"root": tile_root,
|
|
"water_loaded": false,
|
|
"m2_names": baked_m2_names,
|
|
"m2_placements": baked_m2_placements,
|
|
"m2_built": false,
|
|
"m2_unique_keys": [],
|
|
"m2_skipped_unique_keys": [],
|
|
"wmo_names": baked_wmo_names,
|
|
"wmo_placements": baked_wmo_placements,
|
|
"wmo_refs": [],
|
|
}
|
|
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
_request_tile_water_load(state)
|
|
state = _sync_detail_assets(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 _finalize_loaded_streaming_tile(request: Dictionary, stream_tile: Resource) -> void:
|
|
var key: String = request["key"]
|
|
if _tile_states.has(key):
|
|
return
|
|
|
|
var stream_version: int = int(stream_tile.get("format_version"))
|
|
if stream_version < STREAMING_TILE_SCRIPT.FORMAT_VERSION:
|
|
push_warning(
|
|
"Outdated streaming tile cache for %s_%d_%d (format=%d, required=%d). Falling back to baked/raw ADT load." % [
|
|
map_name, request["tx"], request["ty"], stream_version, STREAMING_TILE_SCRIPT.FORMAT_VERSION
|
|
]
|
|
)
|
|
_start_raw_tile_load_async(request)
|
|
return
|
|
|
|
var tile_root := Node3D.new()
|
|
tile_root.name = "Tile_%d_%d" % [request["tx"], request["ty"]]
|
|
_terrain_root.add_child(tile_root)
|
|
_set_editor_owner_recursive(tile_root)
|
|
|
|
var tile_origin: Vector3 = stream_tile.get("tile_origin")
|
|
tile_root.position = tile_origin
|
|
var stream_m2_names_value: Variant = stream_tile.get("m2_names")
|
|
var stream_m2_placements_value: Variant = stream_tile.get("m2_placements")
|
|
var stream_m2_names: PackedStringArray = stream_m2_names_value if stream_m2_names_value is PackedStringArray else PackedStringArray()
|
|
var stream_m2_placements: Array = stream_m2_placements_value if stream_m2_placements_value is Array else []
|
|
var stream_wmo_names_value: Variant = stream_tile.get("wmo_names")
|
|
var stream_wmo_placements_value: Variant = stream_tile.get("wmo_placements")
|
|
var stream_wmo_names: PackedStringArray = stream_wmo_names_value if stream_wmo_names_value is PackedStringArray else PackedStringArray()
|
|
var stream_wmo_placements: Array = stream_wmo_placements_value if stream_wmo_placements_value is Array else []
|
|
var state := {
|
|
"key": key,
|
|
"tx": request["tx"],
|
|
"ty": request["ty"],
|
|
"path": request["path"],
|
|
"origin": tile_origin,
|
|
"data": {},
|
|
"baked_tile": null,
|
|
"stream_tile": stream_tile,
|
|
"tex_names": PackedStringArray(),
|
|
"chunks": {},
|
|
"desired_lods": {},
|
|
"tile_lod": -1,
|
|
"tile_lod_node": null,
|
|
"tile_lod_rid": RID(),
|
|
"tile_lod_mesh": null,
|
|
"tile_lod_visible": false,
|
|
"prepared_tile_lod": -1,
|
|
"prepared_tile_lod_rid": RID(),
|
|
"prepared_tile_lod_mesh": null,
|
|
"desired_tile_lod": -1,
|
|
"desired_tile_lod_visible": false,
|
|
"wanted": true,
|
|
"retained": true,
|
|
"root": tile_root,
|
|
"water_loaded": false,
|
|
"m2_names": stream_m2_names,
|
|
"m2_placements": stream_m2_placements,
|
|
"m2_built": false,
|
|
"m2_unique_keys": [],
|
|
"m2_skipped_unique_keys": [],
|
|
"wmo_names": stream_wmo_names,
|
|
"wmo_placements": stream_wmo_placements,
|
|
"wmo_refs": [],
|
|
}
|
|
|
|
state = _restore_quality_terrain_mesh(key, state)
|
|
state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos)
|
|
state["desired_tile_lod_visible"] = _compute_desired_tile_lod_visible(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
_request_tile_water_load(state)
|
|
state = _sync_terrain_quality(state, _last_focus_pos)
|
|
state = _sync_detail_assets(state, _last_focus_pos)
|
|
_tile_states[key] = state
|
|
_rebuild_chunk_queues()
|
|
if debug_streaming:
|
|
print("StreamingWorld: loaded streaming tile %d,%d" % [request["tx"], request["ty"]])
|
|
|
|
|
|
func _create_tile_lod(op: Dictionary) -> void:
|
|
var key: String = op["tile"]
|
|
if not _tile_states.has(key):
|
|
return
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
var lod: int = int(op["lod"])
|
|
var visible: bool = bool(op.get("visible", true))
|
|
var prepare_only: bool = bool(op.get("prepare", false))
|
|
if int(state.get("tile_lod", -1)) == lod:
|
|
_set_tile_lod_visible(state, visible)
|
|
state["tile_lod_visible"] = visible
|
|
_tile_states[key] = state
|
|
return
|
|
|
|
if prepare_only:
|
|
if int(state.get("prepared_tile_lod", -1)) == lod:
|
|
return
|
|
var prepared_mesh := _get_tile_lod_mesh_for_state(key, state, lod)
|
|
if prepared_mesh == null or not use_rendering_server_for_terrain:
|
|
return
|
|
var prepared_rid := _create_render_instance(
|
|
prepared_mesh,
|
|
_tile_local_to_world_transform(state, Vector3.ZERO))
|
|
if not prepared_rid.is_valid():
|
|
return
|
|
RenderingServer.instance_set_visible(prepared_rid, false)
|
|
var old_prepared_rid: RID = state.get("prepared_tile_lod_rid", RID())
|
|
if old_prepared_rid.is_valid():
|
|
_free_render_instance(old_prepared_rid)
|
|
state["prepared_tile_lod"] = lod
|
|
state["prepared_tile_lod_rid"] = prepared_rid
|
|
state["prepared_tile_lod_mesh"] = prepared_mesh
|
|
_tile_states[key] = state
|
|
return
|
|
|
|
var old_node: Node = state.get("tile_lod_node", null)
|
|
var old_rid: RID = state.get("tile_lod_rid", RID())
|
|
|
|
var prepared_lod: int = int(state.get("prepared_tile_lod", -1))
|
|
var prepared_rid: RID = state.get("prepared_tile_lod_rid", RID())
|
|
if prepared_lod == lod and prepared_rid.is_valid():
|
|
state["tile_lod"] = lod
|
|
state["tile_lod_node"] = null
|
|
state["tile_lod_rid"] = prepared_rid
|
|
state["tile_lod_mesh"] = state.get("prepared_tile_lod_mesh", null)
|
|
state["prepared_tile_lod"] = -1
|
|
state["prepared_tile_lod_rid"] = RID()
|
|
state["prepared_tile_lod_mesh"] = null
|
|
RenderingServer.instance_set_visible(prepared_rid, visible)
|
|
if old_rid.is_valid():
|
|
_free_render_instance(old_rid)
|
|
if old_node:
|
|
old_node.queue_free()
|
|
state["tile_lod_visible"] = visible
|
|
_tile_states[key] = state
|
|
return
|
|
|
|
state["tile_lod"] = lod
|
|
state["tile_lod_node"] = null
|
|
state["tile_lod_rid"] = RID()
|
|
state["tile_lod_mesh"] = null
|
|
|
|
var mesh := _get_tile_lod_mesh_for_state(key, state, lod)
|
|
|
|
if use_rendering_server_for_terrain and mesh:
|
|
var instance_rid := _create_render_instance(
|
|
mesh,
|
|
_tile_local_to_world_transform(state, Vector3.ZERO))
|
|
if instance_rid.is_valid():
|
|
RenderingServer.instance_set_visible(instance_rid, visible)
|
|
state["tile_lod_rid"] = instance_rid
|
|
state["tile_lod_mesh"] = mesh
|
|
|
|
if not state["tile_lod_rid"].is_valid() and mesh:
|
|
var node := MeshInstance3D.new()
|
|
node.name = "TileLOD%d" % lod
|
|
node.mesh = mesh
|
|
node.visible = visible
|
|
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if terrain_cast_shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
state["tile_lod_node"] = node
|
|
state["tile_lod_mesh"] = mesh
|
|
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()
|
|
state["tile_lod_visible"] = visible
|
|
_tile_states[key] = state
|
|
|
|
|
|
func _get_tile_lod_mesh_for_state(key: String, state: Dictionary, lod: int) -> Mesh:
|
|
if lod <= 0:
|
|
var quality_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
|
if quality_mesh != null:
|
|
return quality_mesh
|
|
|
|
var cache_key := _tile_mesh_cache_key(key, lod)
|
|
var mesh: Mesh = _get_cached_tile_mesh(cache_key)
|
|
if mesh != null:
|
|
return mesh
|
|
|
|
mesh = _get_baked_tile_mesh(state, lod)
|
|
if mesh == null:
|
|
var payload: Dictionary = _builder.build_tile_coarse_render_payload(
|
|
state["data"],
|
|
_shared_tex_cache,
|
|
ProjectSettings.globalize_path(extracted_dir),
|
|
lod)
|
|
if not payload.is_empty():
|
|
mesh = payload.get("mesh", null)
|
|
if mesh:
|
|
_store_cached_tile_mesh(cache_key, mesh)
|
|
return mesh
|
|
|
|
|
|
func _create_chunk(op: Dictionary) -> void:
|
|
var key: String = op["tile"]
|
|
if not _tile_states.has(key):
|
|
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 bool(state.get("retained", false)) 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_visible"] = false
|
|
state["tile_lod"] = -1
|
|
|
|
if not bool(state.get("retained", false)) and state["chunks"].is_empty():
|
|
_release_tile_if_unused(key)
|
|
else:
|
|
_tile_states[key] = state
|
|
|
|
|
|
func _set_tile_lod_visible(state: Dictionary, visible: bool) -> void:
|
|
var instance_rid: RID = state.get("tile_lod_rid", RID())
|
|
if instance_rid.is_valid():
|
|
RenderingServer.instance_set_visible(instance_rid, visible)
|
|
var node: Node = state.get("tile_lod_node", null)
|
|
if node and is_instance_valid(node):
|
|
node.visible = visible
|
|
|
|
|
|
func _release_tile(key: String) -> void:
|
|
if not _tile_states.has(key):
|
|
return
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
_unregister_tile_wmos(state)
|
|
_remove_tile_m2_assets(state)
|
|
_terrain_upgrade_tasks.erase(key)
|
|
_terrain_control_splat_cache_tasks.erase(key)
|
|
_terrain_splat_cache_tasks.erase(key)
|
|
_terrain_splat_tasks.erase(key)
|
|
_water_load_queued.erase(key)
|
|
for i in range(_water_load_queue.size() - 1, -1, -1):
|
|
var pending: Dictionary = _water_load_queue[i]
|
|
if String(pending.get("key", "")) == key:
|
|
_water_load_queue.remove_at(i)
|
|
var tile_lod_rid: RID = state.get("tile_lod_rid", RID())
|
|
if tile_lod_rid.is_valid():
|
|
_free_render_instance(tile_lod_rid)
|
|
var prepared_tile_lod_rid: RID = state.get("prepared_tile_lod_rid", RID())
|
|
if prepared_tile_lod_rid.is_valid():
|
|
_free_render_instance(prepared_tile_lod_rid)
|
|
for entry in state.get("chunks", {}).values():
|
|
var chunk_rid: RID = entry.get("rid", RID())
|
|
if chunk_rid.is_valid():
|
|
_free_render_instance(chunk_rid)
|
|
var tile_root: Node = state.get("root", null)
|
|
if tile_root:
|
|
tile_root.queue_free()
|
|
_remove_tile_mesh_cache_entries(key)
|
|
_tile_states.erase(key)
|
|
|
|
|
|
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()
|
|
_terrain_upgrade_tasks.clear()
|
|
_terrain_control_splat_cache_tasks.clear()
|
|
_terrain_splat_cache_tasks.clear()
|
|
_terrain_splat_tasks.clear()
|
|
_terrain_splat_result_mutex.lock()
|
|
_terrain_splat_result_queue.clear()
|
|
_terrain_splat_result_mutex.unlock()
|
|
_water_load_queue.clear()
|
|
_water_load_queued.clear()
|
|
_water_load_tasks.clear()
|
|
_water_result_mutex.lock()
|
|
_water_result_queue.clear()
|
|
_water_result_mutex.unlock()
|
|
_detail_asset_queue.clear()
|
|
_detail_asset_queued.clear()
|
|
for key in _m2_build_jobs.keys():
|
|
_cancel_m2_build_job(String(key))
|
|
_m2_build_queue.clear()
|
|
_m2_unique_registry.clear()
|
|
_m2_mesh_load_requests.clear()
|
|
_m2_mesh_finalize_queue.clear()
|
|
_m2_runtime_rebuild_required_cache.clear()
|
|
_m2_animation_load_requests.clear()
|
|
_m2_animation_finalize_queue.clear()
|
|
_wmo_build_jobs.clear()
|
|
_wmo_build_queue.clear()
|
|
_wmo_scene_load_requests.clear()
|
|
_wmo_render_load_requests.clear()
|
|
_wmo_render_missing_cache.clear()
|
|
_wmo_scene_cache_missing.clear()
|
|
_wmo_render_build_jobs.clear()
|
|
_wmo_render_build_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)
|
|
var prepared_tile_lod_rid: RID = state.get("prepared_tile_lod_rid", RID())
|
|
if prepared_tile_lod_rid.is_valid():
|
|
_free_render_instance(prepared_tile_lod_rid)
|
|
for entry in state.get("chunks", {}).values():
|
|
var chunk_rid: RID = entry.get("rid", RID())
|
|
if chunk_rid.is_valid():
|
|
_free_render_instance(chunk_rid)
|
|
var tile_root: Node = state.get("root", null)
|
|
if tile_root:
|
|
_queue_free_streamed_node(tile_root)
|
|
|
|
_tile_states.clear()
|
|
_clear_tile_mesh_cache()
|
|
_clear_quality_terrain_mesh_cache()
|
|
_clear_wmo_registry()
|
|
if not _shutting_down:
|
|
_ensure_world_wmo_root()
|
|
if enable_wmo:
|
|
_place_global_wmo()
|
|
|
|
|
|
## During world shutdown, parented nodes are recursively owned by the world
|
|
## root. Queueing them separately from `_exit_tree()` can detach them from that
|
|
## deletion and leave empty Node objects alive at engine teardown.
|
|
func _queue_free_streamed_node(node: Node) -> void:
|
|
if node == null or not is_instance_valid(node):
|
|
return
|
|
if _shutting_down and node.is_inside_tree():
|
|
return
|
|
node.queue_free()
|
|
|
|
|
|
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 _runtime_visible_tile_radius() -> int:
|
|
var chunk_tile_radius: int = int(ceil(float(lod2_radius_chunks) / 16.0)) + warm_tile_margin
|
|
return maxi(chunk_tile_radius, lod2_tile_radius)
|
|
|
|
|
|
func _runtime_load_tile_radius() -> int:
|
|
return _runtime_visible_tile_radius() + maxi(0, prewarm_tile_margin)
|
|
|
|
|
|
func _runtime_retain_tile_radius() -> int:
|
|
return _runtime_load_tile_radius() + maxi(0, retain_tile_margin)
|
|
|
|
|
|
func _predictive_focus_tiles(focus_pos: Vector3) -> Array[Vector2i]:
|
|
var base := _world_to_tile(focus_pos)
|
|
var result: Array[Vector2i] = [base]
|
|
var threshold: float = clampf(boundary_prefetch_threshold, 0.0, 0.49)
|
|
if threshold <= 0.0:
|
|
return result
|
|
|
|
var local_x := fposmod(focus_pos.x, TILE_SIZE) / TILE_SIZE
|
|
var local_z := fposmod(focus_pos.z, TILE_SIZE) / TILE_SIZE
|
|
var x_offsets: Array[int] = [0]
|
|
var z_offsets: Array[int] = [0]
|
|
|
|
if local_x <= threshold:
|
|
x_offsets.append(-1)
|
|
elif local_x >= 1.0 - threshold:
|
|
x_offsets.append(1)
|
|
|
|
if local_z <= threshold:
|
|
z_offsets.append(-1)
|
|
elif local_z >= 1.0 - threshold:
|
|
z_offsets.append(1)
|
|
|
|
for ox in x_offsets:
|
|
for oz in z_offsets:
|
|
if ox == 0 and oz == 0:
|
|
continue
|
|
result.append(Vector2i(base.x + ox, base.y + oz))
|
|
|
|
return result
|
|
|
|
|
|
func _compute_desired_tile_lod(state: Dictionary, cam_pos: Vector3) -> int:
|
|
var tile_dist_sq: float = _tile_dist_sq(state, cam_pos)
|
|
var chunk_radius_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2
|
|
var tile_radius_sq: float = (lod2_tile_radius * TILE_SIZE) ** 2
|
|
var prewarm_radius_sq: float = (_runtime_load_tile_radius() * TILE_SIZE) ** 2
|
|
if state.get("stream_tile", null) != null:
|
|
if _state_has_applicable_quality_terrain(state, cam_pos):
|
|
return 0
|
|
if _should_hide_terrain_fallback_for_quality(state, cam_pos):
|
|
return -1
|
|
if state.get("baked_tile", null) != null and _tile_within_full_terrain_radius(state, cam_pos):
|
|
return 0
|
|
return 3 if tile_dist_sq <= prewarm_radius_sq else -1
|
|
|
|
if not use_chunk_terrain_lods:
|
|
if tile_dist_sq <= chunk_radius_sq:
|
|
return 0
|
|
if tile_dist_sq <= tile_radius_sq:
|
|
return 3
|
|
if prewarm_tile_lod_instances and tile_dist_sq <= prewarm_radius_sq:
|
|
return 3
|
|
return -1
|
|
|
|
if tile_dist_sq <= chunk_radius_sq:
|
|
return -1
|
|
if tile_dist_sq <= tile_radius_sq:
|
|
return 3
|
|
return -1
|
|
|
|
|
|
func _compute_desired_tile_lod_visible(state: Dictionary, cam_pos: Vector3) -> bool:
|
|
var tile_dist_sq: float = _tile_dist_sq(state, cam_pos)
|
|
var chunk_radius_sq: float = (lod2_radius_chunks * CHUNK_SIZE) ** 2
|
|
var tile_radius_sq: float = (lod2_tile_radius * TILE_SIZE) ** 2
|
|
return tile_dist_sq <= maxf(chunk_radius_sq, tile_radius_sq)
|
|
|
|
|
|
func _tile_within_full_terrain_radius(state: Dictionary, cam_pos: Vector3) -> bool:
|
|
var radius_tiles := maxi(0, terrain_full_quality_radius_tiles)
|
|
var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2
|
|
return _tile_dist_sq(state, cam_pos) <= radius_sq
|
|
|
|
|
|
func _tile_within_control_splat_terrain_radius(state: Dictionary, cam_pos: Vector3) -> bool:
|
|
var radius_tiles := maxi(0, terrain_control_splat_radius_tiles)
|
|
var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2
|
|
return _tile_dist_sq(state, cam_pos) <= radius_sq
|
|
|
|
|
|
func _tile_within_splat_terrain_radius(state: Dictionary, cam_pos: Vector3) -> bool:
|
|
var radius_tiles := maxi(0, terrain_splat_radius_tiles)
|
|
var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2
|
|
return _tile_dist_sq(state, cam_pos) <= radius_sq
|
|
|
|
|
|
func _should_hide_terrain_fallback_for_quality(state: Dictionary, cam_pos: Vector3) -> bool:
|
|
if not terrain_control_splat_hide_fallback:
|
|
return false
|
|
if not terrain_control_splat_primary or not terrain_control_splat_quality_enabled:
|
|
return false
|
|
if not bool(state.get("wanted", false)):
|
|
return false
|
|
var radius_tiles := maxi(0, terrain_control_splat_hide_fallback_radius_tiles)
|
|
var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2
|
|
if _tile_dist_sq(state, cam_pos) > radius_sq:
|
|
return false
|
|
if String(state.get("quality_terrain_source", "")) == "control_splat_cache":
|
|
return false
|
|
var control_path := _get_terrain_control_splat_resource_path(int(state.get("tx", 0)), int(state.get("ty", 0)))
|
|
return ResourceLoader.exists(control_path)
|
|
|
|
|
|
func _should_prepare_full_tile_lod(state: Dictionary, cam_pos: Vector3) -> bool:
|
|
if use_chunk_terrain_lods or not prewarm_tile_lod_instances:
|
|
return false
|
|
if state.get("stream_tile", null) != null:
|
|
return false
|
|
if int(state.get("tile_lod", -1)) <= 0:
|
|
return false
|
|
if int(state.get("prepared_tile_lod", -1)) == 0:
|
|
return false
|
|
|
|
var tile_dist_sq: float = _tile_dist_sq(state, cam_pos)
|
|
var chunk_radius: float = float(lod2_radius_chunks) * CHUNK_SIZE
|
|
var prepare_radius: float = chunk_radius + maxf(0.0, full_lod_prewarm_tiles) * TILE_SIZE
|
|
return tile_dist_sq <= prepare_radius * prepare_radius
|
|
|
|
|
|
func _is_tile_chunk_set_ready(state: Dictionary) -> bool:
|
|
var desired_lods: Dictionary = state.get("desired_lods", {})
|
|
var current_chunks: Dictionary = state.get("chunks", {})
|
|
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 _capture_streaming_focus_from_source() -> void:
|
|
if streaming_focus_source_path == NodePath():
|
|
return
|
|
var focus_source := get_node_or_null(streaming_focus_source_path) as Node3D
|
|
if focus_source == null:
|
|
if not _missing_focus_source_reported:
|
|
push_warning("Streaming focus source is missing or is not Node3D: %s" % streaming_focus_source_path)
|
|
_missing_focus_source_reported = true
|
|
return
|
|
_missing_focus_source_reported = false
|
|
_set_streaming_focus_from_vector3(focus_source.global_position)
|
|
|
|
|
|
func _set_streaming_focus_from_vector3(world_position: Vector3) -> void:
|
|
var typed_world_position = GODOT_WORLD_POSITION_SCRIPT.new(
|
|
world_position.x,
|
|
world_position.y,
|
|
world_position.z
|
|
)
|
|
set_streaming_focus(STREAMING_FOCUS_SCRIPT.new(typed_world_position))
|
|
|
|
|
|
func _streaming_focus_to_vector3() -> Vector3:
|
|
var world_position: GodotWorldPosition = _streaming_focus.world_position
|
|
return Vector3(world_position.x_units, world_position.y_units, world_position.z_units)
|
|
|
|
|
|
func _get_editor_view_camera() -> Camera3D:
|
|
# EditorInterface owns this camera. This method is the explicit editor adapter;
|
|
# runtime streaming never discovers a viewport camera.
|
|
var editor_viewport := EditorInterface.get_editor_viewport_3d(0)
|
|
if editor_viewport == null:
|
|
return null
|
|
return editor_viewport.get_camera_3d()
|
|
|
|
|
|
func _get_auto_position_camera() -> Camera3D:
|
|
if camera_path == NodePath():
|
|
return null
|
|
return get_node_or_null(camera_path) as Camera3D
|
|
|
|
|
|
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_streaming_tile_resource_path(tx: int, ty: int) -> String:
|
|
return streaming_tile_cache_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty])
|
|
|
|
|
|
func _get_terrain_control_splat_resource_path(tx: int, ty: int) -> String:
|
|
return terrain_control_splat_cache_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty])
|
|
|
|
|
|
func _get_terrain_splat_resource_path(tx: int, ty: int) -> String:
|
|
return terrain_splat_cache_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty])
|
|
|
|
|
|
func _terrain_splat_queue_size() -> int:
|
|
return _terrain_splat_cache_tasks.size() + _terrain_splat_tasks.size() + _terrain_splat_result_queue.size()
|
|
|
|
|
|
func _state_has_applicable_quality_terrain(state: Dictionary, cam_pos: Vector3) -> bool:
|
|
var quality_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
|
if quality_mesh == null:
|
|
return false
|
|
var quality_source := String(state.get("quality_terrain_source", ""))
|
|
if quality_source == "control_splat_cache":
|
|
return _tile_within_control_splat_terrain_radius(state, cam_pos)
|
|
if quality_source == "splat_cache" or quality_source == "splat":
|
|
return _tile_within_splat_terrain_radius(state, cam_pos)
|
|
if quality_source == "baked_full":
|
|
return _tile_within_full_terrain_radius(state, cam_pos)
|
|
return _tile_within_full_terrain_radius(state, cam_pos)
|
|
|
|
|
|
func _get_baked_tile_mesh(state: Dictionary, lod: int) -> Mesh:
|
|
var stream_tile: Resource = state.get("stream_tile", null)
|
|
var baked_tile: Resource = state.get("baked_tile", null)
|
|
if stream_tile != null:
|
|
if lod <= 0:
|
|
var quality_mesh: Mesh = state.get("quality_terrain_mesh", null)
|
|
if quality_mesh != null:
|
|
return quality_mesh
|
|
return stream_tile.get("terrain_mesh")
|
|
if baked_tile == null:
|
|
return null
|
|
if lod <= 0:
|
|
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 _ensure_world_wmo_root() -> void:
|
|
if _world_wmo_root != null and is_instance_valid(_world_wmo_root):
|
|
return
|
|
_world_wmo_root = Node3D.new()
|
|
_world_wmo_root.name = "WMOs"
|
|
_terrain_root.add_child(_world_wmo_root)
|
|
_set_editor_owner_recursive(_world_wmo_root)
|
|
|
|
|
|
func _wdt_has_global_wmo() -> bool:
|
|
return bool(_wdt_info.get("has_global_wmo", false)) and _wdt_info.has("global_wmo_name")
|
|
|
|
|
|
func _place_global_wmo() -> void:
|
|
if not _wdt_has_global_wmo():
|
|
return
|
|
if _wmo_registry.has("global"):
|
|
return
|
|
|
|
_ensure_world_wmo_root()
|
|
|
|
var rel_path := String(_wdt_info.get("global_wmo_name", "")).replace("\\", "/")
|
|
if rel_path.is_empty():
|
|
return
|
|
|
|
var placement: Dictionary = _wdt_info.get("global_wmo_placement", {})
|
|
if placement.is_empty():
|
|
placement = {
|
|
"pos": Vector3.ZERO,
|
|
"rot": Vector3.ZERO,
|
|
"scale": 1.0,
|
|
}
|
|
|
|
var instance := _instantiate_wmo_world(rel_path, placement)
|
|
if instance == null:
|
|
push_warning("StreamingWorld: failed to load global WMO: %s" % rel_path)
|
|
return
|
|
|
|
_world_wmo_root.add_child(instance)
|
|
_set_editor_owner_recursive(instance)
|
|
_wmo_registry["global"] = {
|
|
"node": instance,
|
|
"refs": {"__global__": true},
|
|
}
|
|
|
|
|
|
func _sync_detail_assets(state: Dictionary, focus_pos: Vector3) -> Dictionary:
|
|
if not bool(state.get("wanted", false)):
|
|
_disable_tile_detail_assets(state)
|
|
return state
|
|
|
|
var tile_root: Node = state.get("root", null)
|
|
if tile_root == null or not is_instance_valid(tile_root):
|
|
return state
|
|
|
|
if enable_wmo and _tile_within_detail_radius(state, focus_pos, wmo_tile_radius):
|
|
var refs: Array = state.get("wmo_refs", [])
|
|
var wmo_placements: Array = state.get("wmo_placements", [])
|
|
var wmo_key := String(state.get("key", ""))
|
|
if refs.is_empty() and not wmo_placements.is_empty() and not _wmo_build_jobs.has(wmo_key):
|
|
_enqueue_detail_asset_sync(String(state.get("key", "")))
|
|
else:
|
|
_unregister_tile_wmos(state)
|
|
|
|
if enable_m2_assets and _tile_within_detail_radius(state, focus_pos, m2_tile_radius):
|
|
var key := String(state.get("key", ""))
|
|
var m2_placements: Array = state.get("m2_placements", [])
|
|
if (
|
|
not bool(state.get("m2_built", false))
|
|
and not m2_placements.is_empty()
|
|
and not _m2_group_tasks.has(key)
|
|
and not _m2_build_jobs.has(key)
|
|
):
|
|
_enqueue_detail_asset_sync(key)
|
|
else:
|
|
_remove_tile_m2_assets(state)
|
|
state["m2_built"] = false
|
|
|
|
return state
|
|
|
|
|
|
func _enqueue_detail_asset_sync(key: String) -> void:
|
|
if key.is_empty() or _detail_asset_queued.has(key):
|
|
return
|
|
_detail_asset_queued[key] = true
|
|
_detail_asset_queue.append(key)
|
|
|
|
|
|
func _process_detail_asset_queue() -> void:
|
|
var ops_left: int = maxi(0, detail_asset_ops_per_tick)
|
|
while ops_left > 0 and not _detail_asset_queue.is_empty():
|
|
var key: String = String(_detail_asset_queue.pop_front())
|
|
_detail_asset_queued.erase(key)
|
|
_process_detail_asset_key(key)
|
|
ops_left -= 1
|
|
|
|
|
|
func _process_detail_asset_key(key: String) -> void:
|
|
if not _tile_states.has(key):
|
|
return
|
|
var state: Dictionary = _tile_states[key]
|
|
if not bool(state.get("wanted", false)):
|
|
_disable_tile_detail_assets(state)
|
|
_tile_states[key] = state
|
|
return
|
|
|
|
var tile_root: Node = state.get("root", null)
|
|
if tile_root == null or not is_instance_valid(tile_root):
|
|
return
|
|
|
|
if enable_wmo and _tile_within_detail_radius(state, _last_focus_pos, wmo_tile_radius):
|
|
var refs: Array = state.get("wmo_refs", [])
|
|
if refs.is_empty():
|
|
_register_tile_wmos(
|
|
state,
|
|
state.get("wmo_names", PackedStringArray()),
|
|
state.get("wmo_placements", []))
|
|
else:
|
|
_unregister_tile_wmos(state)
|
|
|
|
if enable_m2_assets and _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius):
|
|
if not bool(state.get("m2_built", false)):
|
|
var m2_request := _request_tile_m2_assets(
|
|
tile_root as Node3D,
|
|
String(state.get("key", "")),
|
|
state.get("origin", Vector3.ZERO),
|
|
state.get("m2_names", PackedStringArray()),
|
|
state.get("m2_placements", []))
|
|
state["m2_built"] = bool(m2_request.get("built", false))
|
|
if m2_request.has("unique_keys"):
|
|
state["m2_unique_keys"] = m2_request.get("unique_keys", [])
|
|
if m2_request.has("skipped_unique_keys"):
|
|
state["m2_skipped_unique_keys"] = m2_request.get("skipped_unique_keys", [])
|
|
else:
|
|
_remove_tile_m2_assets(state)
|
|
state["m2_built"] = false
|
|
|
|
_tile_states[key] = state
|
|
|
|
|
|
func _disable_tile_detail_assets(state: Dictionary) -> void:
|
|
_unregister_tile_wmos(state)
|
|
_remove_tile_m2_assets(state)
|
|
state["m2_built"] = false
|
|
|
|
|
|
func _tile_within_detail_radius(state: Dictionary, focus_pos: Vector3, radius_tiles: int) -> bool:
|
|
if radius_tiles < 0:
|
|
return true
|
|
var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2
|
|
return _tile_dist_sq(state, focus_pos) <= radius_sq
|
|
|
|
|
|
func _register_tile_wmos(state: Dictionary, wmo_names: PackedStringArray, wmo_placements: Array) -> void:
|
|
var refs: Array = state.get("wmo_refs", [])
|
|
if wmo_names.is_empty() or wmo_placements.is_empty():
|
|
state["wmo_refs"] = refs
|
|
return
|
|
|
|
_ensure_world_wmo_root()
|
|
var tile_key: String = state["key"]
|
|
if _wmo_build_jobs.has(tile_key):
|
|
state["wmo_building"] = true
|
|
return
|
|
|
|
_wmo_build_jobs[tile_key] = {
|
|
"names": wmo_names,
|
|
"placements": wmo_placements,
|
|
"index": 0,
|
|
"refs": refs,
|
|
}
|
|
_wmo_build_queue.append(tile_key)
|
|
state["wmo_building"] = true
|
|
|
|
|
|
func _process_wmo_build_jobs() -> void:
|
|
_drain_wmo_render_loads()
|
|
_drain_wmo_scene_loads()
|
|
var ops_left: int = maxi(0, wmo_build_instances_per_tick)
|
|
while ops_left > 0 and not _wmo_build_queue.is_empty():
|
|
var tile_key: String = String(_wmo_build_queue.front())
|
|
if not _wmo_build_jobs.has(tile_key):
|
|
_wmo_build_queue.pop_front()
|
|
continue
|
|
if not _tile_states.has(tile_key):
|
|
_wmo_build_jobs.erase(tile_key)
|
|
_wmo_build_queue.pop_front()
|
|
continue
|
|
|
|
var state: Dictionary = _tile_states[tile_key]
|
|
if not bool(state.get("wanted", false)) or not _tile_within_detail_radius(state, _last_focus_pos, wmo_tile_radius):
|
|
_cancel_wmo_build_job(tile_key)
|
|
_unregister_tile_wmos(state)
|
|
_tile_states[tile_key] = state
|
|
continue
|
|
|
|
var job: Dictionary = _wmo_build_jobs[tile_key]
|
|
var wmo_names: PackedStringArray = job.get("names", PackedStringArray())
|
|
var wmo_placements: Array = job.get("placements", [])
|
|
var refs: Array = job.get("refs", [])
|
|
var index: int = int(job.get("index", 0))
|
|
if index >= wmo_placements.size():
|
|
state["wmo_refs"] = refs
|
|
state["wmo_building"] = false
|
|
_tile_states[tile_key] = state
|
|
_wmo_build_jobs.erase(tile_key)
|
|
_wmo_build_queue.pop_front()
|
|
continue
|
|
|
|
var placement_variant = wmo_placements[index]
|
|
var advance_job := true
|
|
if placement_variant is Dictionary:
|
|
var placement: Dictionary = placement_variant
|
|
var name_id: int = int(placement.get("name_id", -1))
|
|
if name_id >= 0 and name_id < wmo_names.size():
|
|
var unique_key := _wmo_unique_key(placement, tile_key, index)
|
|
if _wmo_registry.has(unique_key):
|
|
var entry: Dictionary = _wmo_registry[unique_key]
|
|
var entry_refs: Dictionary = entry["refs"]
|
|
entry_refs[tile_key] = true
|
|
refs.append(unique_key)
|
|
else:
|
|
var rel_path: String = str(wmo_names[name_id]).replace("\\", "/")
|
|
var render_resource := _get_wmo_render_or_request(rel_path)
|
|
if render_resource != null:
|
|
var instance := _instantiate_wmo_render_root(rel_path, placement)
|
|
if instance != null:
|
|
_world_wmo_root.add_child(instance)
|
|
_set_editor_owner_recursive(instance)
|
|
_wmo_registry[unique_key] = {
|
|
"node": instance,
|
|
"refs": {tile_key: true},
|
|
}
|
|
refs.append(unique_key)
|
|
_start_wmo_render_build(unique_key, instance, render_resource)
|
|
else:
|
|
var normalized_rel := _normalize_wmo_rel_path(rel_path)
|
|
if _wmo_render_load_requests.has(normalized_rel):
|
|
advance_job = false
|
|
else:
|
|
var scene := _get_wmo_scene_or_request(rel_path)
|
|
if scene == null:
|
|
if _wmo_scene_load_requests.has(normalized_rel):
|
|
advance_job = false
|
|
else:
|
|
var live_instance := _instantiate_wmo_world(rel_path, placement)
|
|
if live_instance != null:
|
|
_prepare_runtime_wmo_instance(live_instance)
|
|
_world_wmo_root.add_child(live_instance)
|
|
_set_editor_owner_recursive(live_instance)
|
|
_wmo_registry[unique_key] = {
|
|
"node": live_instance,
|
|
"refs": {tile_key: true},
|
|
}
|
|
refs.append(unique_key)
|
|
else:
|
|
var scene_instance := _instantiate_wmo_scene(rel_path, scene, placement)
|
|
if scene_instance != null:
|
|
_prepare_runtime_wmo_instance(scene_instance)
|
|
_world_wmo_root.add_child(scene_instance)
|
|
_set_editor_owner_recursive(scene_instance)
|
|
_wmo_registry[unique_key] = {
|
|
"node": scene_instance,
|
|
"refs": {tile_key: true},
|
|
}
|
|
refs.append(unique_key)
|
|
if advance_job:
|
|
job["refs"] = refs
|
|
|
|
if not advance_job:
|
|
_wmo_build_jobs[tile_key] = job
|
|
state["wmo_refs"] = refs
|
|
state["wmo_building"] = true
|
|
_tile_states[tile_key] = state
|
|
_wmo_build_queue.pop_front()
|
|
_wmo_build_queue.append(tile_key)
|
|
ops_left -= 1
|
|
continue
|
|
|
|
job["index"] = index + 1
|
|
_wmo_build_jobs[tile_key] = job
|
|
state["wmo_refs"] = refs
|
|
state["wmo_building"] = true
|
|
_tile_states[tile_key] = state
|
|
ops_left -= 1
|
|
|
|
if int(job["index"]) >= wmo_placements.size():
|
|
state["wmo_refs"] = refs
|
|
state["wmo_building"] = false
|
|
_tile_states[tile_key] = state
|
|
_wmo_build_jobs.erase(tile_key)
|
|
_wmo_build_queue.pop_front()
|
|
|
|
|
|
func _drain_wmo_scene_loads() -> void:
|
|
for normalized_rel_variant in _wmo_scene_load_requests.keys():
|
|
var normalized_rel := String(normalized_rel_variant)
|
|
var path := String(_wmo_scene_load_requests[normalized_rel])
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED:
|
|
continue
|
|
_wmo_scene_load_requests.erase(normalized_rel)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED:
|
|
_wmo_scene_cache_missing[normalized_rel] = true
|
|
continue
|
|
var resource: Resource = ResourceLoader.load_threaded_get(path)
|
|
if resource is PackedScene and _is_wmo_scene_cache_current(resource as PackedScene):
|
|
_wmo_scene_resource_cache[normalized_rel] = resource as PackedScene
|
|
else:
|
|
_wmo_scene_cache_missing[normalized_rel] = true
|
|
|
|
|
|
func _drain_wmo_render_loads() -> void:
|
|
for normalized_rel_variant in _wmo_render_load_requests.keys():
|
|
var normalized_rel := String(normalized_rel_variant)
|
|
var path := String(_wmo_render_load_requests[normalized_rel])
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED:
|
|
continue
|
|
_wmo_render_load_requests.erase(normalized_rel)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED:
|
|
_wmo_render_missing_cache[normalized_rel] = true
|
|
continue
|
|
var resource: Resource = ResourceLoader.load_threaded_get(path)
|
|
if (
|
|
resource != null
|
|
and resource.get_script() == WMO_STREAMING_SCRIPT
|
|
and int(resource.get("format_version")) >= WMO_STREAMING_SCRIPT.FORMAT_VERSION
|
|
):
|
|
_wmo_render_cache[normalized_rel] = resource
|
|
else:
|
|
_wmo_render_missing_cache[normalized_rel] = true
|
|
|
|
|
|
func _normalize_wmo_rel_path(rel_path: String) -> String:
|
|
return rel_path.replace("\\", "/").to_lower()
|
|
|
|
|
|
func _get_wmo_render_or_request(rel_path: String) -> Resource:
|
|
var normalized_rel := _normalize_wmo_rel_path(rel_path)
|
|
if normalized_rel.is_empty():
|
|
return null
|
|
if _wmo_render_cache.has(normalized_rel):
|
|
return _wmo_render_cache[normalized_rel]
|
|
if _wmo_render_missing_cache.has(normalized_rel):
|
|
return null
|
|
if _wmo_render_load_requests.has(normalized_rel):
|
|
return null
|
|
|
|
var cache_path := wmo_render_cache_dir.path_join(normalized_rel.get_basename() + ".res")
|
|
if not ResourceLoader.exists(cache_path):
|
|
return null
|
|
|
|
var err := ResourceLoader.load_threaded_request(
|
|
cache_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_REUSE)
|
|
if err == OK or err == ERR_BUSY:
|
|
_wmo_render_load_requests[normalized_rel] = cache_path
|
|
return null
|
|
|
|
|
|
func _get_wmo_scene_or_request(rel_path: String) -> PackedScene:
|
|
var normalized_rel := _normalize_wmo_rel_path(rel_path)
|
|
if normalized_rel.is_empty():
|
|
return null
|
|
if _wmo_scene_resource_cache.has(normalized_rel):
|
|
return _wmo_scene_resource_cache[normalized_rel]
|
|
if _wmo_scene_cache_missing.has(normalized_rel):
|
|
return null
|
|
if _wmo_scene_load_requests.has(normalized_rel):
|
|
return null
|
|
|
|
var cache_path := wmo_cache_dir.path_join(normalized_rel.get_basename() + ".tscn")
|
|
if not ResourceLoader.exists(cache_path):
|
|
_wmo_scene_cache_missing[normalized_rel] = true
|
|
return null
|
|
|
|
if wmo_max_runtime_scene_mb > 0.0:
|
|
var size_bytes := _get_resource_file_size(cache_path)
|
|
var max_bytes := int(wmo_max_runtime_scene_mb * 1024.0 * 1024.0)
|
|
if size_bytes > max_bytes:
|
|
_wmo_scene_cache_missing[normalized_rel] = true
|
|
if debug_streaming:
|
|
print("StreamingWorld: skipping large WMO %.2f MB: %s" % [
|
|
float(size_bytes) / (1024.0 * 1024.0),
|
|
normalized_rel,
|
|
])
|
|
return null
|
|
|
|
var err := ResourceLoader.load_threaded_request(
|
|
cache_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_REUSE)
|
|
if err == OK or err == ERR_BUSY:
|
|
_wmo_scene_load_requests[normalized_rel] = cache_path
|
|
else:
|
|
_wmo_scene_cache_missing[normalized_rel] = true
|
|
return null
|
|
|
|
|
|
func _instantiate_wmo_render_root(rel_path: String, placement: Dictionary) -> Node3D:
|
|
var root := Node3D.new()
|
|
root.name = rel_path.get_file().get_basename()
|
|
root.position = placement.get("pos", Vector3.ZERO)
|
|
root.rotation = placement.get("rot", Vector3.ZERO)
|
|
var scale_value: float = float(placement.get("scale", 1.0))
|
|
root.scale = Vector3.ONE * scale_value
|
|
return root
|
|
|
|
|
|
func _start_wmo_render_build(unique_key: String, root: Node3D, render_resource: Resource) -> void:
|
|
if unique_key.is_empty() or root == null or render_resource == null:
|
|
return
|
|
_wmo_render_build_jobs[unique_key] = {
|
|
"root": root,
|
|
"resource": render_resource,
|
|
"mesh_index": 0,
|
|
"multimesh_index": 0,
|
|
}
|
|
_wmo_render_build_queue.append(unique_key)
|
|
|
|
|
|
func _process_wmo_render_build_jobs() -> void:
|
|
var ops_left: int = maxi(0, wmo_render_group_ops_per_tick)
|
|
while ops_left > 0 and not _wmo_render_build_queue.is_empty():
|
|
var unique_key := String(_wmo_render_build_queue.front())
|
|
if not _wmo_render_build_jobs.has(unique_key):
|
|
_wmo_render_build_queue.pop_front()
|
|
continue
|
|
|
|
var job: Dictionary = _wmo_render_build_jobs[unique_key]
|
|
var root: Node = job.get("root", null)
|
|
if root == null or not is_instance_valid(root):
|
|
_cancel_wmo_render_build_job(unique_key)
|
|
continue
|
|
|
|
var render_resource: Resource = job.get("resource", null)
|
|
if render_resource == null:
|
|
_cancel_wmo_render_build_job(unique_key)
|
|
continue
|
|
|
|
var meshes: Array = render_resource.get("meshes")
|
|
var mesh_transforms: Array = render_resource.get("mesh_transforms")
|
|
var mesh_names: PackedStringArray = render_resource.get("mesh_names")
|
|
var mesh_index: int = int(job.get("mesh_index", 0))
|
|
if mesh_index < meshes.size():
|
|
var mesh := meshes[mesh_index] as Mesh
|
|
if mesh != null:
|
|
_refresh_cached_wmo_mesh_materials(mesh)
|
|
var mesh_instance := MeshInstance3D.new()
|
|
mesh_instance.name = mesh_names[mesh_index] if mesh_index < mesh_names.size() else "Group_%d" % mesh_index
|
|
mesh_instance.mesh = mesh
|
|
if mesh_index < mesh_transforms.size():
|
|
mesh_instance.transform = mesh_transforms[mesh_index]
|
|
mesh_instance.cast_shadow = (
|
|
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
|
if wmo_cast_shadows
|
|
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
)
|
|
if wmo_visibility_range > 0.0:
|
|
mesh_instance.visibility_range_end = wmo_visibility_range
|
|
mesh_instance.visibility_range_end_margin = CHUNK_SIZE
|
|
(root as Node3D).add_child(mesh_instance)
|
|
_set_editor_owner_recursive(mesh_instance)
|
|
job["mesh_index"] = mesh_index + 1
|
|
_wmo_render_build_jobs[unique_key] = job
|
|
ops_left -= 1
|
|
continue
|
|
|
|
var multimeshes: Array = render_resource.get("multimeshes")
|
|
var multimesh_transforms: Array = render_resource.get("multimesh_transforms")
|
|
var multimesh_names: PackedStringArray = render_resource.get("multimesh_names")
|
|
var multimesh_index: int = int(job.get("multimesh_index", 0))
|
|
if multimesh_index < multimeshes.size():
|
|
var multimesh := multimeshes[multimesh_index] as MultiMesh
|
|
if multimesh != null:
|
|
_refresh_cached_wmo_mesh_materials(multimesh.mesh)
|
|
var multimesh_instance := MultiMeshInstance3D.new()
|
|
multimesh_instance.name = (
|
|
multimesh_names[multimesh_index]
|
|
if multimesh_index < multimesh_names.size()
|
|
else "DoodadGroup_%d" % multimesh_index
|
|
)
|
|
multimesh_instance.multimesh = multimesh
|
|
if multimesh_index < multimesh_transforms.size():
|
|
multimesh_instance.transform = multimesh_transforms[multimesh_index]
|
|
multimesh_instance.cast_shadow = (
|
|
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
|
if wmo_cast_shadows
|
|
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
)
|
|
if wmo_visibility_range > 0.0:
|
|
multimesh_instance.visibility_range_end = wmo_visibility_range
|
|
multimesh_instance.visibility_range_end_margin = CHUNK_SIZE
|
|
(root as Node3D).add_child(multimesh_instance)
|
|
_set_editor_owner_recursive(multimesh_instance)
|
|
job["multimesh_index"] = multimesh_index + 1
|
|
_wmo_render_build_jobs[unique_key] = job
|
|
ops_left -= 1
|
|
continue
|
|
|
|
_cancel_wmo_render_build_job(unique_key)
|
|
|
|
|
|
func _cancel_wmo_render_build_job(unique_key: String) -> void:
|
|
if _wmo_render_build_jobs.has(unique_key):
|
|
_wmo_render_build_jobs.erase(unique_key)
|
|
_wmo_render_build_queue.erase(unique_key)
|
|
|
|
|
|
func _get_resource_file_size(path: String) -> int:
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
file = FileAccess.open(ProjectSettings.globalize_path(path), FileAccess.READ)
|
|
if file == null:
|
|
return 0
|
|
var length := file.get_length()
|
|
file.close()
|
|
return int(length)
|
|
|
|
|
|
func _is_wmo_scene_cache_current(scene: PackedScene) -> bool:
|
|
if scene == null:
|
|
return false
|
|
var instance := scene.instantiate() as Node3D
|
|
if instance == null:
|
|
return false
|
|
var current := _is_wmo_node_cache_current(instance)
|
|
instance.free()
|
|
return current
|
|
|
|
|
|
func _is_wmo_node_cache_current(node: Node) -> bool:
|
|
if node == null:
|
|
return false
|
|
return bool(WMO_BUILDER_SCRIPT.is_scene_cache_current(node))
|
|
|
|
|
|
func _instantiate_wmo_scene(rel_path: String, scene: PackedScene, placement: Dictionary) -> Node3D:
|
|
if scene == null:
|
|
return null
|
|
var instance := scene.instantiate() as Node3D
|
|
if instance == null:
|
|
return null
|
|
if not _is_wmo_node_cache_current(instance):
|
|
instance.free()
|
|
return null
|
|
instance.name = rel_path.get_file().get_basename()
|
|
instance.position = placement.get("pos", Vector3.ZERO)
|
|
instance.rotation = placement.get("rot", Vector3.ZERO)
|
|
var scale_value: float = float(placement.get("scale", 1.0))
|
|
instance.scale = Vector3.ONE * scale_value
|
|
return instance
|
|
|
|
|
|
func _prepare_runtime_wmo_instance(instance: Node3D) -> void:
|
|
_refresh_cached_wmo_materials_recursive(instance)
|
|
if not enable_occlusion_culling:
|
|
var occluders := instance.get_node_or_null("Occluders")
|
|
if occluders != null:
|
|
instance.remove_child(occluders)
|
|
occluders.queue_free()
|
|
if wmo_cast_shadows:
|
|
_apply_shadow_cast_recursive(instance, true)
|
|
|
|
|
|
func _refresh_cached_wmo_materials_recursive(node: Node) -> void:
|
|
if node is MeshInstance3D:
|
|
_refresh_cached_wmo_mesh_materials((node as MeshInstance3D).mesh)
|
|
elif node is MultiMeshInstance3D:
|
|
var multimesh := (node as MultiMeshInstance3D).multimesh
|
|
if multimesh != null:
|
|
_refresh_cached_wmo_mesh_materials(multimesh.mesh)
|
|
for child in node.get_children():
|
|
_refresh_cached_wmo_materials_recursive(child)
|
|
|
|
|
|
func _refresh_cached_wmo_mesh_materials(mesh: Mesh) -> void:
|
|
if mesh == null:
|
|
return
|
|
if int(mesh.get_meta("wow_wmo_material_refresh_version", 0)) >= WMO_MATERIAL_REFRESH_VERSION:
|
|
return
|
|
mesh.set_meta("wow_wmo_material_refresh_version", WMO_MATERIAL_REFRESH_VERSION)
|
|
if not (mesh is ArrayMesh):
|
|
return
|
|
|
|
var array_mesh := mesh as ArrayMesh
|
|
for surface_idx in array_mesh.get_surface_count():
|
|
var rebuilt := _rebuild_cached_wmo_material(array_mesh.surface_get_material(surface_idx))
|
|
if rebuilt != null:
|
|
array_mesh.surface_set_material(surface_idx, rebuilt)
|
|
|
|
|
|
func _rebuild_cached_wmo_material(material: Material) -> Material:
|
|
if material == null or not material.has_meta("texture0_path"):
|
|
return null
|
|
|
|
var tex0_path := String(material.get_meta("texture0_path", ""))
|
|
var tex1_path := String(material.get_meta("texture1_path", ""))
|
|
var tex2_path := String(material.get_meta("texture2_path", ""))
|
|
var textures := PackedStringArray()
|
|
var tex0_index := -1
|
|
var tex1_index := -1
|
|
var tex2_index := -1
|
|
if not tex0_path.is_empty():
|
|
tex0_index = textures.size()
|
|
textures.append(tex0_path)
|
|
if not tex1_path.is_empty():
|
|
tex1_index = textures.size()
|
|
textures.append(tex1_path)
|
|
if not tex2_path.is_empty():
|
|
tex2_index = textures.size()
|
|
textures.append(tex2_path)
|
|
|
|
var diffuse := Color.WHITE
|
|
var emissive := Color.BLACK
|
|
var secondary := Color.WHITE
|
|
if material is ShaderMaterial:
|
|
var shader_material := material as ShaderMaterial
|
|
var diffuse_value: Variant = shader_material.get_shader_parameter("diffuse_color")
|
|
var emissive_value: Variant = shader_material.get_shader_parameter("emissive_color")
|
|
var secondary_value: Variant = shader_material.get_shader_parameter("secondary_color")
|
|
if diffuse_value is Color:
|
|
diffuse = diffuse_value
|
|
if emissive_value is Color:
|
|
emissive = emissive_value
|
|
if secondary_value is Color:
|
|
secondary = secondary_value
|
|
|
|
var mat_def := {
|
|
"texture0": tex0_index,
|
|
"texture1": tex1_index,
|
|
"texture2": tex2_index,
|
|
"flags": int(material.get_meta("wow_flags", 0)),
|
|
"shader": int(material.get_meta("wow_shader", 0)),
|
|
"blend_mode": int(material.get_meta("wow_blend_mode", 0)),
|
|
"diffuse_color": diffuse,
|
|
"emissive_color": emissive,
|
|
"color2": secondary,
|
|
}
|
|
return WMO_BUILDER_SCRIPT._build_material(mat_def, textures, extracted_dir)
|
|
|
|
|
|
func _adt_m2_placement_basis(rot: Vector3, rel_path: String = "") -> Basis:
|
|
if not _is_waterfall_m2_path(rel_path):
|
|
return _adt_m2_regular_placement_basis(rot, rel_path)
|
|
|
|
# ADTLoader stores MDDF yaw as raw_yaw - 90 degrees for historical Godot
|
|
# facing alignment. In our tile-positive ADT world, waterfall models need a
|
|
# world-space +90 degree yaw to anchor their top to the source pool. Twist
|
|
# the sheet around its own local fall axis afterwards, so the anchor stays in
|
|
# place but the alpha plane is not rendered edge-on.
|
|
var basis := Basis.from_euler(rot)
|
|
return (
|
|
Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET)
|
|
* basis
|
|
* Basis(_waterfall_local_fall_axis(rel_path), _waterfall_sheet_twist(rel_path))
|
|
)
|
|
|
|
|
|
func _adt_m2_regular_placement_basis(rot: Vector3, rel_path: String = "") -> Basis:
|
|
if not _is_elwynn_cliffrock_m2_path(rel_path):
|
|
return Basis.from_euler(rot)
|
|
|
|
# Elwynn cliff rock M2s are open-backed shell meshes. Plain Godot Euler
|
|
# points their hollow side out of the cliff at waterfall placements. Keep the
|
|
# correction model-specific so normal props retain the native ADT rotation.
|
|
return (
|
|
Basis(Vector3.UP, rot.y + ADT_CLIFFROCK_WORLD_YAW_OFFSET)
|
|
* Basis(Vector3.BACK, rot.x)
|
|
* Basis(Vector3.RIGHT, -rot.z)
|
|
)
|
|
|
|
|
|
func _adt_m2_placement_origin_offset(rot: Vector3, rel_path: String, scale_value: float) -> Vector3:
|
|
if not _is_waterfall_m2_path(rel_path):
|
|
return Vector3.ZERO
|
|
var twist := _waterfall_sheet_twist(rel_path)
|
|
if is_zero_approx(twist):
|
|
return Vector3.ZERO
|
|
var anchor := _waterfall_sheet_twist_anchor(rel_path)
|
|
if anchor == Vector3.ZERO:
|
|
return Vector3.ZERO
|
|
var base := Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rot)
|
|
var twisted := base * Basis(_waterfall_local_fall_axis(rel_path), twist)
|
|
return (base * anchor - twisted * anchor) * maxf(scale_value, 0.0001)
|
|
|
|
|
|
func _is_waterfall_m2_path(rel_path: String) -> bool:
|
|
var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename()
|
|
return name == "newwaterfall" or name == "elwynntallwaterfall01"
|
|
|
|
|
|
func _is_elwynn_cliffrock_m2_path(rel_path: String) -> bool:
|
|
var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename()
|
|
return name == "elwynncliffrock01" or name == "elwynncliffrock02"
|
|
|
|
|
|
func _waterfall_local_fall_axis(rel_path: String) -> Vector3:
|
|
var normalized := rel_path.replace("\\", "/").to_lower()
|
|
if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"):
|
|
return Vector3(-0.138742, 0.990329, 0.0).normalized()
|
|
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
|
|
return Vector3(-0.068962, 0.997619, 0.0).normalized()
|
|
return Vector3.UP
|
|
|
|
|
|
func _waterfall_sheet_twist(rel_path: String) -> float:
|
|
var normalized := rel_path.replace("\\", "/").to_lower()
|
|
if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"):
|
|
return PI * 0.5
|
|
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
|
|
return -PI * 0.5
|
|
return 0.0
|
|
|
|
|
|
func _waterfall_sheet_twist_anchor(rel_path: String) -> Vector3:
|
|
var normalized := rel_path.replace("\\", "/").to_lower()
|
|
if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"):
|
|
return Vector3(-2.667799, 89.62273, 0.00129)
|
|
return Vector3.ZERO
|
|
|
|
|
|
func _cancel_wmo_build_job(tile_key: String) -> void:
|
|
if _wmo_build_jobs.has(tile_key):
|
|
_wmo_build_jobs.erase(tile_key)
|
|
_wmo_build_queue.erase(tile_key)
|
|
|
|
|
|
func _unregister_tile_wmos(state: Dictionary) -> void:
|
|
var refs: Array = state.get("wmo_refs", [])
|
|
var tile_key: String = String(state.get("key", ""))
|
|
if not tile_key.is_empty():
|
|
_cancel_wmo_build_job(tile_key)
|
|
if refs.is_empty():
|
|
state["wmo_building"] = false
|
|
return
|
|
|
|
for unique_key in refs:
|
|
if not _wmo_registry.has(unique_key):
|
|
continue
|
|
var entry: Dictionary = _wmo_registry[unique_key]
|
|
var entry_refs: Dictionary = entry["refs"]
|
|
entry_refs.erase(tile_key)
|
|
if entry_refs.is_empty():
|
|
_cancel_wmo_render_build_job(String(unique_key))
|
|
var node: Node = entry.get("node", null)
|
|
if node:
|
|
node.queue_free()
|
|
_wmo_registry.erase(unique_key)
|
|
|
|
state["wmo_refs"] = []
|
|
state["wmo_building"] = false
|
|
|
|
|
|
func _wmo_unique_key(placement: Dictionary, tile_key: String, idx: int) -> String:
|
|
var uid: int = int(placement.get("unique_id", -1))
|
|
# uniqueId is always set in vanilla/3.3.5a MODF data, but older baked caches
|
|
# predating the ADTLoader unique_id field will miss it. Fall back to a
|
|
# per-tile synthetic key — no cross-tile dedup, but also no collisions.
|
|
if uid > 0:
|
|
return "uid:%d" % uid
|
|
return "tile:%s:%d" % [tile_key, idx]
|
|
|
|
|
|
func _clear_wmo_registry() -> void:
|
|
_wmo_render_build_jobs.clear()
|
|
_wmo_render_build_queue.clear()
|
|
for entry_variant in _wmo_registry.values():
|
|
if not (entry_variant is Dictionary):
|
|
continue
|
|
var entry: Dictionary = entry_variant
|
|
var node: Node = entry.get("node", null)
|
|
if node and is_instance_valid(node):
|
|
_queue_free_streamed_node(node)
|
|
_wmo_registry.clear()
|
|
if _world_wmo_root and is_instance_valid(_world_wmo_root):
|
|
_queue_free_streamed_node(_world_wmo_root)
|
|
_world_wmo_root = null
|
|
|
|
|
|
func _request_tile_m2_assets(
|
|
tile_root: Node3D,
|
|
key: String,
|
|
tile_origin: Vector3,
|
|
m2_names: PackedStringArray,
|
|
m2_placements: Array) -> Dictionary:
|
|
var result := {
|
|
"built": false,
|
|
}
|
|
if key.is_empty() or m2_names.is_empty() or m2_placements.is_empty():
|
|
return result
|
|
var existing_m2_root := tile_root.get_node_or_null("M2s")
|
|
if existing_m2_root != null:
|
|
result["built"] = not existing_m2_root.is_queued_for_deletion()
|
|
return result
|
|
if _m2_group_tasks.has(key) or _m2_build_jobs.has(key):
|
|
return result
|
|
|
|
var filtered := _reserve_tile_m2_placements(key, m2_placements)
|
|
var filtered_placements: Array = filtered.get("placements", [])
|
|
result["unique_keys"] = filtered.get("unique_keys", [])
|
|
result["skipped_unique_keys"] = filtered.get("skipped_unique_keys", [])
|
|
if filtered_placements.is_empty():
|
|
result["built"] = true
|
|
return result
|
|
|
|
var task_id: int = WorkerThreadPool.add_task(
|
|
_group_tile_m2_task.bind(key, tile_origin, m2_names, filtered_placements))
|
|
_m2_group_tasks[key] = task_id
|
|
return result
|
|
|
|
|
|
func _reserve_tile_m2_placements(tile_key: String, m2_placements: Array) -> Dictionary:
|
|
var filtered_placements: Array = []
|
|
var unique_keys: Array = []
|
|
var skipped_unique_keys: Array = []
|
|
for index in m2_placements.size():
|
|
var placement_variant = m2_placements[index]
|
|
if not (placement_variant is Dictionary):
|
|
continue
|
|
var placement: Dictionary = placement_variant
|
|
var unique_key := _m2_unique_key(placement)
|
|
if unique_key.is_empty():
|
|
filtered_placements.append(placement)
|
|
continue
|
|
if _m2_unique_registry.has(unique_key):
|
|
var entry: Dictionary = _m2_unique_registry[unique_key]
|
|
var owner_tile := String(entry.get("tile", ""))
|
|
if owner_tile == tile_key and unique_keys.has(unique_key):
|
|
continue
|
|
if owner_tile != tile_key:
|
|
if not skipped_unique_keys.has(unique_key):
|
|
skipped_unique_keys.append(unique_key)
|
|
continue
|
|
_m2_unique_registry[unique_key] = {"tile": tile_key}
|
|
if not unique_keys.has(unique_key):
|
|
unique_keys.append(unique_key)
|
|
filtered_placements.append(placement)
|
|
return {
|
|
"placements": filtered_placements,
|
|
"unique_keys": unique_keys,
|
|
"skipped_unique_keys": skipped_unique_keys,
|
|
}
|
|
|
|
|
|
func _m2_unique_key(placement: Dictionary) -> String:
|
|
var uid: int = int(placement.get("unique_id", -1))
|
|
if uid <= 0:
|
|
return ""
|
|
return "uid:%d" % uid
|
|
|
|
|
|
func _release_tile_m2_unique_keys(state: Dictionary, notify_candidates: bool = true) -> void:
|
|
var tile_key := String(state.get("key", ""))
|
|
var unique_keys: Array = state.get("m2_unique_keys", [])
|
|
if tile_key.is_empty() or unique_keys.is_empty():
|
|
state["m2_unique_keys"] = []
|
|
state["m2_skipped_unique_keys"] = []
|
|
return
|
|
|
|
var released_keys: Array = []
|
|
for unique_key_variant in unique_keys:
|
|
var unique_key := String(unique_key_variant)
|
|
if unique_key.is_empty() or not _m2_unique_registry.has(unique_key):
|
|
continue
|
|
var entry: Dictionary = _m2_unique_registry[unique_key]
|
|
if String(entry.get("tile", "")) != tile_key:
|
|
continue
|
|
_m2_unique_registry.erase(unique_key)
|
|
released_keys.append(unique_key)
|
|
|
|
state["m2_unique_keys"] = []
|
|
state["m2_skipped_unique_keys"] = []
|
|
if not notify_candidates:
|
|
return
|
|
for unique_key in released_keys:
|
|
_queue_m2_tiles_skipping_unique_key(String(unique_key), tile_key)
|
|
|
|
|
|
func _queue_m2_tiles_skipping_unique_key(unique_key: String, released_tile_key: String) -> void:
|
|
if unique_key.is_empty():
|
|
return
|
|
for tile_key_variant in _tile_states.keys():
|
|
var tile_key := String(tile_key_variant)
|
|
if tile_key == released_tile_key:
|
|
continue
|
|
var state: Dictionary = _tile_states[tile_key]
|
|
var skipped_keys: Array = state.get("m2_skipped_unique_keys", [])
|
|
if not skipped_keys.has(unique_key):
|
|
continue
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
if not _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius):
|
|
continue
|
|
_cancel_m2_build_job(tile_key)
|
|
_remove_tile_m2_root(state)
|
|
state["m2_built"] = false
|
|
state["m2_skipped_unique_keys"] = []
|
|
_tile_states[tile_key] = state
|
|
_enqueue_detail_asset_sync(tile_key)
|
|
|
|
|
|
func _group_tile_m2_task(
|
|
key: String,
|
|
tile_origin: Vector3,
|
|
m2_names: PackedStringArray,
|
|
m2_placements: Array) -> void:
|
|
var groups: Dictionary = {}
|
|
for placement_variant in m2_placements:
|
|
if not (placement_variant is Dictionary):
|
|
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 normalized: String = str(m2_names[name_id]).replace("\\", "/")
|
|
if normalized.ends_with(".mdx") or normalized.ends_with(".mdl"):
|
|
normalized = normalized.get_basename() + ".m2"
|
|
if normalized.is_empty():
|
|
continue
|
|
|
|
var world_pos: Vector3 = placement.get("pos", Vector3.ZERO)
|
|
var pos: Vector3 = world_pos - tile_origin
|
|
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
|
|
var scale_value: float = float(placement.get("scale", 1.0))
|
|
var basis := _adt_m2_placement_basis(rot, normalized)
|
|
var offset := _adt_m2_placement_origin_offset(rot, normalized, scale_value)
|
|
var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos + offset)
|
|
|
|
if not groups.has(normalized):
|
|
groups[normalized] = []
|
|
(groups[normalized] as Array).append(xform)
|
|
|
|
_m2_group_result_mutex.lock()
|
|
_m2_group_result_queue.append({
|
|
"key": key,
|
|
"groups": groups,
|
|
})
|
|
_m2_group_result_mutex.unlock()
|
|
|
|
|
|
func _drain_m2_group_results() -> void:
|
|
var results: Array = []
|
|
_m2_group_result_mutex.lock()
|
|
if not _m2_group_result_queue.is_empty():
|
|
results = _m2_group_result_queue
|
|
_m2_group_result_queue = []
|
|
_m2_group_result_mutex.unlock()
|
|
|
|
for result_variant in results:
|
|
if not (result_variant is Dictionary):
|
|
continue
|
|
var result: Dictionary = result_variant
|
|
var key: String = String(result.get("key", ""))
|
|
if _m2_group_tasks.has(key):
|
|
WorkerThreadPool.wait_for_task_completion(int(_m2_group_tasks[key]))
|
|
_m2_group_tasks.erase(key)
|
|
if not _tile_states.has(key):
|
|
continue
|
|
var state: Dictionary = _tile_states[key]
|
|
if not bool(state.get("wanted", false)):
|
|
continue
|
|
if not _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius):
|
|
continue
|
|
if bool(state.get("m2_built", false)) or _m2_build_jobs.has(key):
|
|
continue
|
|
|
|
var groups: Dictionary = result.get("groups", {})
|
|
if groups.is_empty():
|
|
state["m2_built"] = true
|
|
_tile_states[key] = state
|
|
continue
|
|
|
|
var tile_root: Node = state.get("root", null)
|
|
if tile_root == null or not is_instance_valid(tile_root):
|
|
continue
|
|
var m2_root := Node3D.new()
|
|
m2_root.name = "M2s"
|
|
(tile_root as Node3D).add_child(m2_root)
|
|
_set_editor_owner_recursive(m2_root)
|
|
|
|
_m2_build_jobs[key] = {
|
|
"root": m2_root,
|
|
"groups": groups,
|
|
"keys": groups.keys(),
|
|
"index": 0,
|
|
"offset": 0,
|
|
"serial": 0,
|
|
}
|
|
_m2_build_queue.append(key)
|
|
|
|
|
|
func _drain_m2_animation_loads() -> void:
|
|
for normalized_rel_variant in _m2_animation_load_requests.keys():
|
|
var normalized_rel := String(normalized_rel_variant)
|
|
var pending: Dictionary = _m2_animation_load_requests[normalized_rel]
|
|
var path := String(pending.get("path", ""))
|
|
if path.is_empty():
|
|
_m2_animation_load_requests.erase(normalized_rel)
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED:
|
|
continue
|
|
pending["status"] = status
|
|
_m2_animation_load_requests.erase(normalized_rel)
|
|
_m2_animation_finalize_queue.append(pending)
|
|
|
|
var ops_left: int = maxi(0, m2_animation_finalize_ops_per_tick)
|
|
while ops_left > 0 and not _m2_animation_finalize_queue.is_empty():
|
|
var pending: Dictionary = _m2_animation_finalize_queue.pop_front()
|
|
var normalized_rel := String(pending.get("normalized", ""))
|
|
if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel):
|
|
ops_left -= 1
|
|
continue
|
|
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
ops_left -= 1
|
|
continue
|
|
|
|
var path := String(pending.get("path", ""))
|
|
var resource: Resource = ResourceLoader.load_threaded_get(path)
|
|
if resource is PackedScene:
|
|
var node = (resource as PackedScene).instantiate()
|
|
if node is Node3D:
|
|
_repair_m2_animated_materials(normalized_rel, node as Node3D)
|
|
var players := _find_animation_players_recursive(node)
|
|
if not players.is_empty():
|
|
_m2_animated_scene_cache[normalized_rel] = node as Node3D
|
|
if debug_streaming:
|
|
print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()])
|
|
ops_left -= 1
|
|
continue
|
|
node.free()
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
ops_left -= 1
|
|
|
|
|
|
func _drain_m2_mesh_loads() -> void:
|
|
for normalized_rel_variant in _m2_mesh_load_requests.keys():
|
|
var normalized_rel := String(normalized_rel_variant)
|
|
var pending: Dictionary = _m2_mesh_load_requests[normalized_rel]
|
|
var path: String = String(pending.get("path", ""))
|
|
if path.is_empty():
|
|
_m2_mesh_load_requests.erase(normalized_rel)
|
|
_m2_missing_cache[normalized_rel] = true
|
|
continue
|
|
var status := ResourceLoader.load_threaded_get_status(path)
|
|
if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED:
|
|
continue
|
|
pending["status"] = status
|
|
_m2_mesh_load_requests.erase(normalized_rel)
|
|
_m2_mesh_finalize_queue.append(pending)
|
|
|
|
var ops_left: int = maxi(0, m2_mesh_finalize_ops_per_tick)
|
|
while ops_left > 0 and not _m2_mesh_finalize_queue.is_empty():
|
|
var pending: Dictionary = _m2_mesh_finalize_queue.pop_front()
|
|
var normalized_rel := String(pending.get("normalized", ""))
|
|
if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel):
|
|
ops_left -= 1
|
|
continue
|
|
if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED:
|
|
_m2_missing_cache[normalized_rel] = true
|
|
ops_left -= 1
|
|
continue
|
|
|
|
var path := String(pending.get("path", ""))
|
|
var resource: Resource = ResourceLoader.load_threaded_get(path)
|
|
var mesh := _extract_first_mesh_from_m2_resource(resource)
|
|
if mesh != null:
|
|
_m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh)
|
|
else:
|
|
_m2_missing_cache[normalized_rel] = true
|
|
ops_left -= 1
|
|
|
|
|
|
func _process_m2_build_jobs() -> void:
|
|
var ops_left: int = maxi(0, m2_build_groups_per_tick)
|
|
while ops_left > 0 and not _m2_build_queue.is_empty():
|
|
var key: String = String(_m2_build_queue.front())
|
|
if not _m2_build_jobs.has(key):
|
|
_m2_build_queue.pop_front()
|
|
continue
|
|
if not _tile_states.has(key):
|
|
_cancel_m2_build_job(key)
|
|
_m2_build_queue.pop_front()
|
|
continue
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
if not bool(state.get("wanted", false)) or not _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius):
|
|
_cancel_m2_build_job(key)
|
|
_release_tile_m2_unique_keys(state)
|
|
state["m2_built"] = false
|
|
_tile_states[key] = state
|
|
_m2_build_queue.pop_front()
|
|
continue
|
|
|
|
var job: Dictionary = _m2_build_jobs[key]
|
|
var root: Node = job.get("root", null)
|
|
if root == null or not is_instance_valid(root):
|
|
_m2_build_jobs.erase(key)
|
|
_m2_build_queue.pop_front()
|
|
continue
|
|
|
|
var group_keys: Array = job.get("keys", [])
|
|
var index: int = int(job.get("index", 0))
|
|
if index >= group_keys.size():
|
|
_finish_m2_build_job(key)
|
|
_m2_build_queue.pop_front()
|
|
continue
|
|
|
|
var rel_path: String = String(group_keys[index])
|
|
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
|
var groups: Dictionary = job.get("groups", {})
|
|
var transforms: Array = groups.get(rel_path, [])
|
|
var offset: int = int(job.get("offset", 0))
|
|
var animated_prototype: Node3D = null
|
|
if enable_m2_animated_instances:
|
|
animated_prototype = _get_or_load_m2_native_animated_prototype(rel_path)
|
|
if animated_prototype == null:
|
|
animated_prototype = _get_or_load_m2_animated_prototype(rel_path)
|
|
if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel):
|
|
_m2_build_queue.pop_front()
|
|
_m2_build_queue.append(key)
|
|
ops_left -= 1
|
|
continue
|
|
var batch_size: int = (
|
|
maxi(1, m2_animated_instances_per_tick)
|
|
if animated_prototype != null
|
|
else maxi(1, m2_multimesh_batch_size)
|
|
)
|
|
var batch_count: int = mini(batch_size, maxi(0, transforms.size() - offset))
|
|
var serial: int = int(job.get("serial", 0))
|
|
var consumed := batch_count
|
|
if batch_count > 0:
|
|
if animated_prototype != null:
|
|
_materialize_m2_animated_batch(root as Node3D, rel_path, animated_prototype, transforms, offset, batch_count, serial)
|
|
else:
|
|
var mesh := _get_m2_mesh_or_request(rel_path)
|
|
if mesh == null:
|
|
if not _m2_missing_cache.has(normalized_rel):
|
|
_m2_build_queue.pop_front()
|
|
_m2_build_queue.append(key)
|
|
ops_left -= 1
|
|
continue
|
|
else:
|
|
_materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial)
|
|
job["serial"] = serial + 1
|
|
if consumed <= 0 or offset + consumed >= transforms.size():
|
|
job["index"] = index + 1
|
|
job["offset"] = 0
|
|
else:
|
|
job["offset"] = offset + consumed
|
|
_m2_build_jobs[key] = job
|
|
ops_left -= 1
|
|
|
|
if int(job["index"]) >= group_keys.size():
|
|
_finish_m2_build_job(key)
|
|
_m2_build_queue.pop_front()
|
|
|
|
|
|
func _materialize_m2_group_batch(
|
|
m2_root: Node3D,
|
|
rel_path: String,
|
|
mesh: Mesh,
|
|
transforms: Array,
|
|
start: int,
|
|
count: int,
|
|
serial: int) -> void:
|
|
if transforms.is_empty() or count <= 0 or mesh == null:
|
|
return
|
|
|
|
var mm := MultiMesh.new()
|
|
mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
mm.mesh = mesh
|
|
mm.instance_count = count
|
|
for i in count:
|
|
mm.set_instance_transform(i, transforms[start + i])
|
|
|
|
var mmi := MultiMeshInstance3D.new()
|
|
mmi.name = "%s_%d" % [rel_path.get_file().get_basename(), serial]
|
|
mmi.multimesh = mm
|
|
mmi.cast_shadow = (
|
|
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
|
if m2_cast_shadows
|
|
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
)
|
|
if m2_visibility_range > 0.0:
|
|
mmi.visibility_range_end = m2_visibility_range
|
|
mmi.visibility_range_end_margin = CHUNK_SIZE
|
|
m2_root.add_child(mmi)
|
|
_set_editor_owner_recursive(mmi)
|
|
|
|
|
|
func _materialize_m2_animated_batch(
|
|
m2_root: Node3D,
|
|
rel_path: String,
|
|
prototype: Node3D,
|
|
transforms: Array,
|
|
start: int,
|
|
count: int,
|
|
serial: int) -> void:
|
|
if transforms.is_empty() or count <= 0 or prototype == null:
|
|
return
|
|
|
|
var batch_root := Node3D.new()
|
|
batch_root.name = "%s_anim_%d" % [rel_path.get_file().get_basename(), serial]
|
|
for i in count:
|
|
var instance := prototype.duplicate(Node.DUPLICATE_SIGNALS | Node.DUPLICATE_GROUPS | Node.DUPLICATE_SCRIPTS) as Node3D
|
|
if instance == null:
|
|
continue
|
|
instance.name = "%s_%d" % [rel_path.get_file().get_basename(), start + i]
|
|
instance.transform = transforms[start + i]
|
|
_apply_visibility_range_recursive(instance, m2_visibility_range)
|
|
_apply_shadow_cast_recursive(instance, m2_cast_shadows)
|
|
_copy_m2_native_animator_data(prototype, instance)
|
|
batch_root.add_child(instance)
|
|
_start_m2_animations(instance, rel_path, start + i)
|
|
|
|
if batch_root.get_child_count() <= 0:
|
|
batch_root.queue_free()
|
|
return
|
|
m2_root.add_child(batch_root)
|
|
_set_editor_owner_recursive(batch_root)
|
|
|
|
|
|
func _start_m2_animations(root: Node, rel_path: String, instance_index: int) -> void:
|
|
var phase := float(abs(("%s:%d" % [rel_path, instance_index]).hash()) % 1000) / 1000.0
|
|
var native_animators := _find_m2_native_animators_recursive(root)
|
|
for animator in native_animators:
|
|
if animator.has_method("prepare_runtime"):
|
|
animator.prepare_runtime()
|
|
animator.set_phase(phase)
|
|
if debug_streaming and animator.has_method("runtime_debug_state"):
|
|
var state: Dictionary = animator.runtime_debug_state()
|
|
print("M2_NATIVE_ANIMATOR path=%s instance=%d prepared=%s processing=%s mesh=%s bones=%d surfaces=%d length=%.2f" % [
|
|
_normalize_m2_rel_path(rel_path),
|
|
instance_index,
|
|
str(state.get("prepared", false)),
|
|
str(state.get("processing", false)),
|
|
str(state.get("has_mesh", false)),
|
|
int(state.get("bones", 0)),
|
|
int(state.get("surfaces", 0)),
|
|
float(state.get("length", 0.0)),
|
|
])
|
|
|
|
var players := _find_animation_players_recursive(root)
|
|
for player in players:
|
|
var animation_name := _choose_default_m2_animation(player, rel_path)
|
|
if animation_name.is_empty():
|
|
continue
|
|
for name in player.get_animation_list():
|
|
var animation := player.get_animation(name)
|
|
if animation != null:
|
|
animation.loop_mode = Animation.LOOP_LINEAR
|
|
player.play(animation_name)
|
|
var animation := player.get_animation(animation_name)
|
|
if animation != null and animation.length > 0.0:
|
|
player.seek(animation.length * phase, true)
|
|
|
|
|
|
func _copy_m2_native_animator_data(source_root: Node, target_root: Node) -> void:
|
|
var source_animators := _find_m2_native_animators_recursive(source_root)
|
|
var target_animators := _find_m2_native_animators_recursive(target_root)
|
|
for i in range(mini(source_animators.size(), target_animators.size())):
|
|
var source := source_animators[i]
|
|
var target := target_animators[i]
|
|
target.mesh_instance_path = source.mesh_instance_path
|
|
target.bones = source.bones
|
|
target.surfaces = source.surfaces
|
|
target.animation_length = source.animation_length
|
|
target.playback_speed = source.playback_speed
|
|
|
|
|
|
func _find_m2_native_animators_recursive(root: Node) -> Array[Node]:
|
|
var result: Array[Node] = []
|
|
_collect_m2_native_animators(root, result)
|
|
return result
|
|
|
|
|
|
func _collect_m2_native_animators(node: Node, result: Array[Node]) -> void:
|
|
if node.get_script() == M2_NATIVE_ANIMATOR_SCRIPT:
|
|
result.append(node)
|
|
for child in node.get_children():
|
|
_collect_m2_native_animators(child, result)
|
|
|
|
|
|
func _choose_default_m2_animation(player: AnimationPlayer, rel_path: String = "") -> String:
|
|
var lower := rel_path.to_lower()
|
|
var candidates: Array[String] = ["Stand", "Idle", "Run", "Walk"]
|
|
if lower.contains("fish"):
|
|
candidates = ["Run", "Walk", "Swim", "Stand", "Idle", "Death"]
|
|
elif lower.contains("eagle") or lower.contains("bird") or lower.contains("gull"):
|
|
candidates = ["Run", "Walk", "Swim", "Stand", "Idle"]
|
|
for candidate in candidates:
|
|
if player.has_animation(candidate):
|
|
return candidate
|
|
var names := player.get_animation_list()
|
|
for candidate in ["run", "walk", "swim", "stand", "idle", "loop"]:
|
|
for name in names:
|
|
if String(name).to_lower().contains(candidate):
|
|
return String(name)
|
|
return String(names[0]) if not names.is_empty() else ""
|
|
|
|
|
|
func _find_animation_players_recursive(root: Node) -> Array[AnimationPlayer]:
|
|
var result: Array[AnimationPlayer] = []
|
|
_collect_animation_players(root, result)
|
|
return result
|
|
|
|
|
|
func _collect_animation_players(node: Node, result: Array[AnimationPlayer]) -> void:
|
|
if node is AnimationPlayer:
|
|
result.append(node as AnimationPlayer)
|
|
for child in node.get_children():
|
|
_collect_animation_players(child, result)
|
|
|
|
|
|
func _finish_m2_build_job(key: String) -> void:
|
|
if not _m2_build_jobs.has(key):
|
|
return
|
|
var job: Dictionary = _m2_build_jobs[key]
|
|
var root: Node = job.get("root", null)
|
|
var built := false
|
|
if root != null and is_instance_valid(root):
|
|
built = root.get_child_count() > 0
|
|
if not built:
|
|
root.queue_free()
|
|
if _tile_states.has(key):
|
|
var state: Dictionary = _tile_states[key]
|
|
state["m2_built"] = true
|
|
_tile_states[key] = state
|
|
_m2_build_jobs.erase(key)
|
|
|
|
|
|
func _cancel_m2_build_job(key: String) -> void:
|
|
if not _m2_build_jobs.has(key):
|
|
return
|
|
var job: Dictionary = _m2_build_jobs[key]
|
|
var root: Node = job.get("root", null)
|
|
if root != null and is_instance_valid(root):
|
|
_queue_free_streamed_node(root)
|
|
_m2_build_jobs.erase(key)
|
|
|
|
|
|
func _remove_tile_m2_assets(state: Dictionary) -> void:
|
|
var key := String(state.get("key", ""))
|
|
if not key.is_empty():
|
|
_cancel_m2_build_job(key)
|
|
_release_tile_m2_unique_keys(state)
|
|
_remove_tile_m2_root(state)
|
|
|
|
|
|
func _remove_tile_m2_root(state: Dictionary) -> void:
|
|
var tile_root: Node = state.get("root", null)
|
|
if tile_root == null or not is_instance_valid(tile_root):
|
|
return
|
|
var m2_root := tile_root.get_node_or_null("M2s")
|
|
if m2_root:
|
|
m2_root.queue_free()
|
|
|
|
|
|
func _apply_visibility_range_recursive(node: Node, range_end: float) -> void:
|
|
if range_end <= 0.0:
|
|
return
|
|
if node is GeometryInstance3D:
|
|
var geometry := node as GeometryInstance3D
|
|
geometry.visibility_range_end = range_end
|
|
geometry.visibility_range_end_margin = CHUNK_SIZE
|
|
for child in node.get_children():
|
|
_apply_visibility_range_recursive(child, range_end)
|
|
|
|
|
|
func _apply_shadow_cast_recursive(node: Node, cast_shadows: bool) -> void:
|
|
if node is GeometryInstance3D:
|
|
var geometry := node as GeometryInstance3D
|
|
geometry.cast_shadow = (
|
|
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
|
|
if cast_shadows
|
|
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
)
|
|
for child in node.get_children():
|
|
_apply_shadow_cast_recursive(child, cast_shadows)
|
|
|
|
|
|
func _strip_occluders_recursive(node: Node) -> void:
|
|
for child in node.get_children():
|
|
if child is OccluderInstance3D:
|
|
node.remove_child(child)
|
|
child.free()
|
|
else:
|
|
_strip_occluders_recursive(child)
|
|
|
|
|
|
func _normalize_m2_rel_path(rel_path: String) -> String:
|
|
var normalized_rel := rel_path.replace("\\", "/")
|
|
if normalized_rel.ends_with(".mdx") or normalized_rel.ends_with(".mdl"):
|
|
normalized_rel = normalized_rel.get_basename() + ".m2"
|
|
return normalized_rel
|
|
|
|
|
|
func _get_m2_mesh_or_request(rel_path: String) -> Mesh:
|
|
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
|
if normalized_rel.is_empty():
|
|
return null
|
|
if _m2_mesh_cache.has(normalized_rel):
|
|
return _m2_mesh_cache[normalized_rel]
|
|
if _m2_missing_cache.has(normalized_rel):
|
|
return null
|
|
_request_m2_mesh_load(normalized_rel)
|
|
return null
|
|
|
|
|
|
func _request_m2_mesh_load(normalized_rel: String) -> void:
|
|
if normalized_rel.is_empty() or _m2_mesh_load_requests.has(normalized_rel):
|
|
return
|
|
|
|
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn", ".glb"]):
|
|
if not ResourceLoader.exists(cache_res_path):
|
|
continue
|
|
if cache_res_path.get_extension().to_lower() == "glb" and _glb_cache_animation_schema(cache_res_path) == "pivot_prefix_v1":
|
|
continue
|
|
var err := ResourceLoader.load_threaded_request(
|
|
cache_res_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_REUSE)
|
|
if err == OK or err == ERR_BUSY:
|
|
_m2_mesh_load_requests[normalized_rel] = {
|
|
"normalized": normalized_rel,
|
|
"path": cache_res_path,
|
|
}
|
|
return
|
|
break
|
|
|
|
_m2_missing_cache[normalized_rel] = true
|
|
|
|
|
|
func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh:
|
|
if mesh == null:
|
|
return null
|
|
if int(mesh.get_meta("wow_m2_material_refresh_version", 0)) >= M2_MATERIAL_REFRESH_VERSION:
|
|
return mesh
|
|
var data := _load_m2_raw_data_for_refresh(normalized_rel)
|
|
if data.is_empty() or not _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel, data):
|
|
mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION)
|
|
return mesh
|
|
var rebuilt := _rebuild_m2_mesh_from_data(data)
|
|
if rebuilt != null:
|
|
return rebuilt
|
|
mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION)
|
|
return mesh
|
|
|
|
|
|
func _prepare_m2_node_for_runtime(normalized_rel: String, root: Node3D) -> void:
|
|
if root == null:
|
|
return
|
|
for mesh_instance in _find_mesh_instances_recursive(root):
|
|
if mesh_instance == null or mesh_instance.mesh == null:
|
|
continue
|
|
var prepared := _prepare_m2_mesh_for_runtime(normalized_rel, mesh_instance.mesh)
|
|
if prepared != null:
|
|
mesh_instance.mesh = prepared
|
|
|
|
|
|
func _load_m2_raw_data_for_refresh(normalized_rel: String) -> Dictionary:
|
|
if normalized_rel.is_empty() or not ClassDB.class_exists("M2Loader"):
|
|
return {}
|
|
var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(normalized_rel))
|
|
if not FileAccess.file_exists(abs_path):
|
|
return {}
|
|
var loader = ClassDB.instantiate("M2Loader")
|
|
if loader == null:
|
|
return {}
|
|
var data: Variant = loader.call("load_m2", abs_path)
|
|
return data if data is Dictionary else {}
|
|
|
|
|
|
func _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel: String, data: Dictionary) -> bool:
|
|
if _m2_runtime_rebuild_required_cache.has(normalized_rel):
|
|
return bool(_m2_runtime_rebuild_required_cache[normalized_rel])
|
|
var needs_rebuild := _m2_raw_data_has_billboards(data) or _m2_raw_data_has_uv_rotation(data)
|
|
_m2_runtime_rebuild_required_cache[normalized_rel] = needs_rebuild
|
|
return needs_rebuild
|
|
|
|
|
|
func _m2_raw_data_has_billboards(data: Dictionary) -> bool:
|
|
for batch_variant in data.get("batches", []):
|
|
if not (batch_variant is Dictionary):
|
|
continue
|
|
var batch: Dictionary = batch_variant
|
|
if bool(batch.get("has_billboard", false)) or int(batch.get("billboard_vertex_count", 0)) > 0:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _m2_raw_data_has_uv_rotation(data: Dictionary) -> bool:
|
|
var transforms: Array = data.get("texture_transforms", [])
|
|
if transforms.is_empty():
|
|
return false
|
|
var combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array())
|
|
if combos.is_empty():
|
|
return false
|
|
for batch_variant in data.get("batches", []):
|
|
if not (batch_variant is Dictionary):
|
|
continue
|
|
var batch: Dictionary = batch_variant
|
|
var texture_count := maxi(1, int(batch.get("texture_count", 1)))
|
|
var base_combo := int(batch.get("texture_transform_combo_index", -1))
|
|
for stage in mini(texture_count, 4):
|
|
var combo_index := base_combo + stage
|
|
if combo_index < 0 or combo_index >= combos.size():
|
|
continue
|
|
var transform_index := int(combos[combo_index])
|
|
if transform_index < 0 or transform_index >= transforms.size():
|
|
continue
|
|
var transform: Dictionary = transforms[transform_index] if transforms[transform_index] is Dictionary else {}
|
|
var rotation: Vector4 = transform.get("rotation", Vector4(1.0, 0.0, 0.0, 1.0))
|
|
if not rotation.is_equal_approx(Vector4(1.0, 0.0, 0.0, 1.0)):
|
|
return true
|
|
if absf(float(transform.get("rotation_speed", 0.0))) > 0.000001:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _rebuild_m2_mesh_from_data(data: Dictionary) -> Mesh:
|
|
if data.is_empty():
|
|
return null
|
|
var prototype: Node3D = M2_BUILDER_SCRIPT.build(data, extracted_dir)
|
|
if prototype == null:
|
|
return null
|
|
var rebuilt := _find_first_mesh_recursive(prototype)
|
|
prototype.free()
|
|
return rebuilt
|
|
|
|
|
|
func _extract_first_mesh_from_m2_resource(resource: Resource) -> Mesh:
|
|
if resource is Mesh:
|
|
return resource as Mesh
|
|
if resource is PackedScene:
|
|
var node := (resource as PackedScene).instantiate()
|
|
if node == null:
|
|
return null
|
|
var mesh := _find_first_mesh_recursive(node)
|
|
node.free()
|
|
return mesh
|
|
return null
|
|
|
|
|
|
func _find_first_mesh_recursive(node: Node) -> Mesh:
|
|
if node is MeshInstance3D:
|
|
return (node as MeshInstance3D).mesh
|
|
for child in node.get_children():
|
|
var mesh := _find_first_mesh_recursive(child)
|
|
if mesh != null:
|
|
return mesh
|
|
return null
|
|
|
|
|
|
func _get_or_load_m2_mesh(rel_path: String) -> Mesh:
|
|
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
|
if _m2_mesh_cache.has(normalized_rel):
|
|
return _m2_mesh_cache[normalized_rel]
|
|
var prototype: Node3D = _get_or_load_m2_prototype(rel_path)
|
|
if prototype == null:
|
|
return null
|
|
var mesh := _find_first_mesh_recursive(prototype)
|
|
if mesh != null and not normalized_rel.is_empty():
|
|
mesh = _prepare_m2_mesh_for_runtime(normalized_rel, mesh)
|
|
_m2_mesh_cache[normalized_rel] = mesh
|
|
return mesh
|
|
|
|
|
|
func _build_tile_m2_placeholders(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> void:
|
|
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 := _adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.01))
|
|
var xf := Transform3D(xform_basis, local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value))
|
|
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()
|
|
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))
|
|
node.transform = Transform3D(
|
|
_adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.0001)),
|
|
local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value))
|
|
return node
|
|
|
|
|
|
func _instantiate_wmo_world(rel_path: String, placement: Dictionary) -> 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()
|
|
# WMOs are parented to _terrain_root, whose position absorbs the editor
|
|
# offset — use world-space placement.pos directly (no tile_origin subtraction).
|
|
instance.position = placement.get("pos", Vector3.ZERO)
|
|
instance.rotation = placement.get("rot", Vector3.ZERO)
|
|
var scale_value: float = float(placement.get("scale", 1.0))
|
|
instance.scale = Vector3.ONE * scale_value
|
|
if not enable_occlusion_culling:
|
|
var occluders := instance.get_node_or_null("Occluders")
|
|
if occluders != null:
|
|
instance.remove_child(occluders)
|
|
occluders.queue_free()
|
|
if wmo_cast_shadows:
|
|
_apply_shadow_cast_recursive(instance, true)
|
|
return instance
|
|
|
|
|
|
func _get_or_load_m2_prototype(rel_path: String) -> Node3D:
|
|
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
|
if normalized_rel.is_empty():
|
|
return null
|
|
if _m2_scene_cache.has(normalized_rel):
|
|
var cached: Node3D = _m2_scene_cache[normalized_rel]
|
|
_prepare_m2_node_for_runtime(normalized_rel, cached)
|
|
return cached
|
|
if _m2_missing_cache.has(normalized_rel):
|
|
return null
|
|
|
|
# Try pre-baked cache (.tscn first, then legacy .glb)
|
|
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn", ".glb"]):
|
|
if not ResourceLoader.exists(cache_res_path):
|
|
continue
|
|
if cache_res_path.get_extension().to_lower() == "glb" and _glb_cache_animation_schema(cache_res_path) == "pivot_prefix_v1":
|
|
continue
|
|
var resource: Resource = load(cache_res_path)
|
|
if resource is PackedScene:
|
|
var node = (resource as PackedScene).instantiate()
|
|
if node is Node3D:
|
|
_prepare_m2_node_for_runtime(normalized_rel, node as Node3D)
|
|
_m2_scene_cache[normalized_rel] = node as Node3D
|
|
return node as Node3D
|
|
break
|
|
|
|
# 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_m2_native_animated_prototype(rel_path: String) -> Node3D:
|
|
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
|
if normalized_rel.is_empty() or not _is_m2_native_animation_candidate(normalized_rel):
|
|
return null
|
|
if _m2_animated_scene_cache.has(normalized_rel):
|
|
return _m2_animated_scene_cache[normalized_rel]
|
|
if _m2_static_animation_cache.has(normalized_rel):
|
|
return null
|
|
if not ClassDB.class_exists("M2Loader"):
|
|
_m2_static_animation_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_static_animation_cache[normalized_rel] = true
|
|
return null
|
|
|
|
var loader = ClassDB.instantiate("M2Loader")
|
|
if loader == null:
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
return null
|
|
if not loader.has_method("load_m2_animated"):
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
return null
|
|
|
|
var data: Dictionary = loader.call("load_m2_animated", abs_path)
|
|
var animated_surfaces: Array = data.get("animated_surfaces", [])
|
|
if data.is_empty() or animated_surfaces.is_empty():
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
return null
|
|
|
|
var prototype: Node3D = M2_NATIVE_ANIMATED_BUILDER_SCRIPT.build(data, extracted_dir)
|
|
if prototype == null or prototype.get_child_count() <= 0:
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
return null
|
|
|
|
_m2_animated_scene_cache[normalized_rel] = prototype
|
|
if debug_streaming:
|
|
print("M2_NATIVE_ANIM_CACHE path=%s surfaces=%d bones=%d anim_id=%d seq=%d score=%d length=%.2f" % [
|
|
normalized_rel,
|
|
animated_surfaces.size(),
|
|
(data.get("bones", []) as Array).size(),
|
|
int(data.get("animation_id", -1)),
|
|
int(data.get("animation_sequence_index", -1)),
|
|
int(data.get("animation_activity_score", 0)),
|
|
float(data.get("animation_length", 0.0)),
|
|
])
|
|
return prototype
|
|
|
|
|
|
func _is_m2_native_animation_candidate(normalized_rel: String) -> bool:
|
|
return normalized_rel.to_lower().contains("gryphonroost")
|
|
|
|
|
|
func _get_or_load_m2_animated_prototype(rel_path: String) -> Node3D:
|
|
var normalized_rel := _normalize_m2_rel_path(rel_path)
|
|
if normalized_rel.is_empty():
|
|
return null
|
|
if _m2_animated_scene_cache.has(normalized_rel):
|
|
return _m2_animated_scene_cache[normalized_rel]
|
|
if _m2_static_animation_cache.has(normalized_rel):
|
|
return null
|
|
if _m2_animation_load_requests.has(normalized_rel):
|
|
return null
|
|
|
|
_request_m2_animation_load(normalized_rel)
|
|
return null
|
|
|
|
|
|
func _request_m2_animation_load(normalized_rel: String) -> void:
|
|
if normalized_rel.is_empty() or _m2_animation_load_requests.has(normalized_rel):
|
|
return
|
|
var cache_res_path := _find_m2_animated_glb_cache_path(normalized_rel)
|
|
if cache_res_path.is_empty():
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
return
|
|
var err := ResourceLoader.load_threaded_request(
|
|
cache_res_path,
|
|
"",
|
|
false,
|
|
ResourceLoader.CACHE_MODE_REUSE)
|
|
if err == OK or err == ERR_BUSY:
|
|
_m2_animation_load_requests[normalized_rel] = {
|
|
"normalized": normalized_rel,
|
|
"path": cache_res_path,
|
|
}
|
|
else:
|
|
_m2_static_animation_cache[normalized_rel] = true
|
|
|
|
|
|
func _find_m2_animated_glb_cache_path(normalized_rel: String) -> String:
|
|
if not _is_m2_animation_allowed(normalized_rel):
|
|
return ""
|
|
if _is_m2_animation_denied(normalized_rel):
|
|
return ""
|
|
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".glb"]):
|
|
if not ResourceLoader.exists(cache_res_path):
|
|
continue
|
|
if _glb_cache_is_safe_for_runtime_animation(cache_res_path):
|
|
return cache_res_path
|
|
return ""
|
|
|
|
|
|
func _is_m2_animation_allowed(normalized_rel: String) -> bool:
|
|
if m2_animated_allowlist_patterns.is_empty():
|
|
return false
|
|
var lower := normalized_rel.to_lower()
|
|
for pattern in m2_animated_allowlist_patterns:
|
|
var needle := String(pattern).strip_edges().to_lower()
|
|
if not needle.is_empty() and lower.contains(needle):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _is_m2_animation_denied(normalized_rel: String) -> bool:
|
|
var lower := normalized_rel.to_lower()
|
|
for pattern in m2_animated_denylist_patterns:
|
|
var needle := String(pattern).strip_edges().to_lower()
|
|
if not needle.is_empty() and lower.contains(needle):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _repair_m2_animated_materials(normalized_rel: String, animated_root: Node3D) -> void:
|
|
if animated_root == null:
|
|
return
|
|
var material_root := _get_or_load_m2_material_prototype(normalized_rel)
|
|
if material_root == null:
|
|
return
|
|
|
|
var source_meshes := _find_mesh_instances_recursive(material_root)
|
|
var target_meshes := _find_mesh_instances_recursive(animated_root)
|
|
if source_meshes.is_empty() or target_meshes.is_empty():
|
|
return
|
|
|
|
var fallback_material := _first_mesh_material(source_meshes)
|
|
for i in target_meshes.size():
|
|
var target := target_meshes[i]
|
|
if target == null or target.mesh == null:
|
|
continue
|
|
var source := source_meshes[mini(i, source_meshes.size() - 1)]
|
|
for surface_idx in target.mesh.get_surface_count():
|
|
var material := _mesh_instance_surface_material(source, surface_idx)
|
|
if material == null:
|
|
material = fallback_material
|
|
if material != null:
|
|
target.set_surface_override_material(surface_idx, material)
|
|
|
|
|
|
func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D:
|
|
if normalized_rel.is_empty():
|
|
return null
|
|
if _m2_scene_cache.has(normalized_rel):
|
|
var cached: Node3D = _m2_scene_cache[normalized_rel]
|
|
_prepare_m2_node_for_runtime(normalized_rel, cached)
|
|
return cached
|
|
|
|
for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn"]):
|
|
if not ResourceLoader.exists(cache_res_path):
|
|
continue
|
|
var resource: Resource = load(cache_res_path)
|
|
if resource is PackedScene:
|
|
var node = (resource as PackedScene).instantiate()
|
|
if node is Node3D:
|
|
_prepare_m2_node_for_runtime(normalized_rel, node as Node3D)
|
|
_m2_scene_cache[normalized_rel] = node as Node3D
|
|
return node as Node3D
|
|
return null
|
|
|
|
|
|
func _find_mesh_instances_recursive(root: Node) -> Array[MeshInstance3D]:
|
|
var result: Array[MeshInstance3D] = []
|
|
_collect_mesh_instances(root, result)
|
|
return result
|
|
|
|
|
|
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
|
|
if node is MeshInstance3D:
|
|
result.append(node as MeshInstance3D)
|
|
for child in node.get_children():
|
|
_collect_mesh_instances(child, result)
|
|
|
|
|
|
func _first_mesh_material(meshes: Array[MeshInstance3D]) -> Material:
|
|
for mesh_instance in meshes:
|
|
if mesh_instance == null or mesh_instance.mesh == null:
|
|
continue
|
|
for surface_idx in mesh_instance.mesh.get_surface_count():
|
|
var material := _mesh_instance_surface_material(mesh_instance, surface_idx)
|
|
if material != null:
|
|
return material
|
|
return null
|
|
|
|
|
|
func _mesh_instance_surface_material(mesh_instance: MeshInstance3D, surface_idx: int) -> Material:
|
|
if mesh_instance == null or mesh_instance.mesh == null:
|
|
return null
|
|
if surface_idx < mesh_instance.get_surface_override_material_count():
|
|
var override_material := mesh_instance.get_surface_override_material(surface_idx)
|
|
if override_material != null:
|
|
return override_material
|
|
if surface_idx < mesh_instance.mesh.get_surface_count():
|
|
return mesh_instance.mesh.surface_get_material(surface_idx)
|
|
return null
|
|
|
|
|
|
func _glb_cache_is_safe_for_runtime_animation(cache_res_path: String) -> bool:
|
|
var abs_path := ProjectSettings.globalize_path(cache_res_path)
|
|
if not FileAccess.file_exists(abs_path):
|
|
return false
|
|
var file := FileAccess.open(abs_path, FileAccess.READ)
|
|
if file == null or file.get_length() < 20:
|
|
return false
|
|
var magic := file.get_32()
|
|
var version := file.get_32()
|
|
file.get_32()
|
|
if magic != 0x46546c67 or version != 2:
|
|
return false
|
|
var json_len := int(file.get_32())
|
|
var chunk_type := file.get_32()
|
|
if json_len <= 0 or chunk_type != 0x4e4f534a:
|
|
return false
|
|
var json_text := file.get_buffer(json_len).get_string_from_utf8()
|
|
var parsed = JSON.parse_string(json_text)
|
|
if not (parsed is Dictionary):
|
|
return false
|
|
var gltf := parsed as Dictionary
|
|
var animations: Array = gltf.get("animations", [])
|
|
if animations.is_empty():
|
|
return false
|
|
var primitive_count := _glb_primitive_count(gltf)
|
|
if m2_animated_max_primitives > 0 and primitive_count > m2_animated_max_primitives:
|
|
return false
|
|
var schema := _glb_animation_schema(gltf)
|
|
if schema == "pivot_prefix_v1":
|
|
return true
|
|
# Older simple critter GLBs were generated before the schema marker existed.
|
|
# Accept them only after allowlist/denylist filtering and primitive-count checks.
|
|
if schema.is_empty():
|
|
return true
|
|
if debug_streaming:
|
|
print("M2_ANIM_REJECT schema=%s primitives=%d cache=%s" % [schema, primitive_count, cache_res_path])
|
|
return false
|
|
|
|
|
|
func _glb_primitive_count(gltf: Dictionary) -> int:
|
|
var count := 0
|
|
var meshes: Array = gltf.get("meshes", [])
|
|
for mesh_variant in meshes:
|
|
if not (mesh_variant is Dictionary):
|
|
continue
|
|
var primitives: Array = (mesh_variant as Dictionary).get("primitives", [])
|
|
count += primitives.size()
|
|
return count
|
|
|
|
|
|
func _glb_cache_animation_schema(cache_res_path: String) -> String:
|
|
var abs_path := ProjectSettings.globalize_path(cache_res_path)
|
|
if not FileAccess.file_exists(abs_path):
|
|
return ""
|
|
var file := FileAccess.open(abs_path, FileAccess.READ)
|
|
if file == null or file.get_length() < 20:
|
|
return ""
|
|
var magic := file.get_32()
|
|
var version := file.get_32()
|
|
file.get_32()
|
|
if magic != 0x46546c67 or version != 2:
|
|
return ""
|
|
var json_len := int(file.get_32())
|
|
var chunk_type := file.get_32()
|
|
if json_len <= 0 or chunk_type != 0x4e4f534a:
|
|
return ""
|
|
var parsed = JSON.parse_string(file.get_buffer(json_len).get_string_from_utf8())
|
|
if parsed is Dictionary:
|
|
return _glb_animation_schema(parsed as Dictionary)
|
|
return ""
|
|
|
|
|
|
func _glb_animation_schema(gltf: Dictionary) -> String:
|
|
var asset: Dictionary = gltf.get("asset", {})
|
|
var extras: Dictionary = asset.get("extras", {})
|
|
return String(extras.get("openwc_m2_anim_schema", ""))
|
|
|
|
|
|
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 and _is_wmo_node_cache_current(node):
|
|
_wmo_prototype_cache[normalized_rel] = node as Node3D
|
|
return node as Node3D
|
|
if node is Node:
|
|
(node as Node).free()
|
|
|
|
# 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_m2_cache_resource_paths(rel_path: String, extensions: Array[String]) -> PackedStringArray:
|
|
var normalized := rel_path.replace("\\", "/")
|
|
var lower := normalized.to_lower()
|
|
var stems := [
|
|
normalized.get_basename(),
|
|
lower.get_basename(),
|
|
normalized.get_file().get_basename(),
|
|
lower.get_file().get_basename(),
|
|
]
|
|
var result := PackedStringArray()
|
|
for ext in extensions:
|
|
for stem in stems:
|
|
if stem.is_empty():
|
|
continue
|
|
var path := m2_cache_dir.path_join(stem + ext)
|
|
if not result.has(path):
|
|
result.append(path)
|
|
return result
|
|
|
|
|
|
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 _remove_tile_mesh_cache_entries(tile_key: String) -> void:
|
|
for lod in range(0, 4):
|
|
var cache_key := _tile_mesh_cache_key(tile_key, lod)
|
|
_tile_mesh_cache.erase(cache_key)
|
|
_tile_mesh_cache_order.erase(cache_key)
|
|
|
|
|
|
func _clear_tile_mesh_cache() -> void:
|
|
_tile_mesh_cache.clear()
|
|
_tile_mesh_cache_order.clear()
|
|
|
|
|
|
func _restore_quality_terrain_mesh(tile_key: String, state: Dictionary) -> Dictionary:
|
|
if not _terrain_quality_mesh_cache.has(tile_key):
|
|
return state
|
|
var cached: Dictionary = _terrain_quality_mesh_cache[tile_key]
|
|
var source := String(cached.get("source", "baked_full"))
|
|
if source == "control_splat_cache" or source == "splat_cache" or source == "splat":
|
|
_terrain_quality_mesh_cache.erase(tile_key)
|
|
_terrain_quality_mesh_cache_order.erase(tile_key)
|
|
return state
|
|
var mesh: Mesh = cached.get("mesh", null)
|
|
if mesh == null:
|
|
_terrain_quality_mesh_cache.erase(tile_key)
|
|
_terrain_quality_mesh_cache_order.erase(tile_key)
|
|
return state
|
|
state["quality_terrain_mesh"] = mesh
|
|
state["quality_terrain_source"] = source
|
|
_touch_quality_terrain_mesh_cache(tile_key)
|
|
return state
|
|
|
|
|
|
func _store_quality_terrain_mesh(tile_key: String, mesh: Mesh, source: String) -> void:
|
|
if tile_key.is_empty() or mesh == null:
|
|
return
|
|
if source == "control_splat_cache" or source == "splat_cache" or source == "splat":
|
|
return
|
|
_terrain_quality_mesh_cache[tile_key] = {
|
|
"mesh": mesh,
|
|
"source": source,
|
|
}
|
|
_touch_quality_terrain_mesh_cache(tile_key)
|
|
_trim_quality_terrain_mesh_cache()
|
|
|
|
|
|
func _touch_quality_terrain_mesh_cache(tile_key: String) -> void:
|
|
_terrain_quality_mesh_cache_order.erase(tile_key)
|
|
_terrain_quality_mesh_cache_order.append(tile_key)
|
|
|
|
|
|
func _trim_quality_terrain_mesh_cache() -> void:
|
|
var limit := maxi(0, terrain_quality_mesh_cache_limit)
|
|
while _terrain_quality_mesh_cache_order.size() > limit:
|
|
var oldest: String = _terrain_quality_mesh_cache_order.pop_front()
|
|
_terrain_quality_mesh_cache.erase(oldest)
|
|
|
|
|
|
func _clear_quality_terrain_mesh_cache() -> void:
|
|
_terrain_quality_mesh_cache.clear()
|
|
_terrain_quality_mesh_cache_order.clear()
|
|
|
|
|
|
func _position_camera_over_world() -> void:
|
|
if _camera_initialized:
|
|
return
|
|
|
|
var camera := _get_auto_position_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:
|
|
var values := [
|
|
extracted_dir,
|
|
map_name,
|
|
quality_preset,
|
|
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,
|
|
prewarm_tile_margin,
|
|
1 if prewarm_tile_lod_instances else 0,
|
|
retain_tile_margin,
|
|
1 if terrain_full_quality_enabled else 0,
|
|
terrain_full_quality_radius_tiles,
|
|
max_concurrent_terrain_upgrade_tasks,
|
|
1 if terrain_control_splat_quality_enabled else 0,
|
|
1 if terrain_control_splat_primary else 0,
|
|
1 if terrain_control_splat_hide_fallback else 0,
|
|
terrain_control_splat_hide_fallback_radius_tiles,
|
|
terrain_control_splat_radius_tiles,
|
|
max_active_terrain_control_splat_tiles,
|
|
terrain_control_splat_cache_dir,
|
|
1 if terrain_splat_quality_enabled else 0,
|
|
terrain_splat_radius_tiles,
|
|
max_active_terrain_splat_tiles,
|
|
max_concurrent_terrain_splat_tasks,
|
|
1 if enable_m2_assets else 0,
|
|
1 if enable_m2_placeholders else 0,
|
|
m2_multimesh_batch_size,
|
|
m2_mesh_finalize_ops_per_tick,
|
|
wmo_build_instances_per_tick,
|
|
wmo_render_group_ops_per_tick,
|
|
wmo_max_runtime_scene_mb,
|
|
m2_tile_radius,
|
|
wmo_tile_radius,
|
|
1 if wmo_cast_shadows else 0,
|
|
m2_visibility_range,
|
|
wmo_visibility_range,
|
|
full_lod_prewarm_tiles,
|
|
boundary_prefetch_threshold,
|
|
1 if terrain_quality_log_enabled else 0,
|
|
terrain_quality_log_interval,
|
|
m2_cache_dir,
|
|
wmo_cache_dir,
|
|
wmo_render_cache_dir,
|
|
streaming_tile_cache_dir,
|
|
]
|
|
var parts := PackedStringArray()
|
|
for value in values:
|
|
parts.append(str(value))
|
|
return "|".join(parts)
|
|
|
|
|
|
func _release_tile_if_unused(key: String) -> void:
|
|
if not _tile_states.has(key):
|
|
return
|
|
|
|
var state: Dictionary = _tile_states[key]
|
|
if bool(state.get("retained", false)):
|
|
_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)
|