первые наработки по рендеру

This commit is contained in:
2026-05-23 00:08:32 +04:00
parent 1a56b22e38
commit bba203aa53
25 changed files with 1841 additions and 110 deletions
+311 -59
View File
@@ -10,6 +10,7 @@ const REQUIRED_BAKED_TILE_FORMAT_VERSION := 4
const TILE_SIZE := 533.33333
const CHUNK_SIZE := TILE_SIZE / 16.0
const M2_RIGHT_YAW_OFFSET := 0.0
@export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth"
@@ -51,6 +52,14 @@ const CHUNK_SIZE := TILE_SIZE / 16.0
@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 = 2
## Dynamic WMO render radius in ADT tiles. Negative keeps all loaded WMOs active.
@export var wmo_tile_radius: int = 4
## Godot visibility range applied to instantiated M2 MultiMesh nodes. <= 0 disables it.
@export var m2_visibility_range: float = TILE_SIZE * 3.0
## Godot visibility range applied to instantiated WMO trees. <= 0 disables it.
@export var wmo_visibility_range: float = TILE_SIZE * 5.0
@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
@@ -61,6 +70,7 @@ const CHUNK_SIZE := TILE_SIZE / 16.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 = []
@@ -91,6 +101,8 @@ var _wmo_prototype_cache: Dictionary = {}
var _wmo_missing_cache: Dictionary = {}
var _m2_scene_cache: Dictionary = {}
var _m2_missing_cache: Dictionary = {}
var _world_wmo_root: Node3D
var _wmo_registry: Dictionary = {}
func _ready() -> void:
@@ -110,10 +122,11 @@ func _ready() -> void:
_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():
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():
@@ -121,6 +134,9 @@ func _ready() -> void:
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 enable_wmo:
_place_global_wmo()
if Engine.is_editor_hint():
_tick_editor_streaming()
elif auto_position_camera:
@@ -185,6 +201,64 @@ func _tick_editor_streaming() -> void:
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)
@@ -210,8 +284,6 @@ func _scan_available_tiles() -> void:
_tile_max.x = max(_tile_max.x, tx)
_tile_max.y = max(_tile_max.y, ty)
print("StreamingWorld: found %d ADT tiles for %s" % [_available_tiles.size(), map_name])
func _refresh_streaming_targets(force: bool) -> void:
var camera := _get_stream_camera()
@@ -312,6 +384,7 @@ func _apply_streaming_target(wanted_tiles: Dictionary, focus_pos: Vector3) -> vo
else:
state["desired_lods"] = {}
state["desired_tile_lod"] = -1
state = _sync_detail_assets(state, focus_pos)
_tile_states[key] = state
_rebuild_chunk_queues()
@@ -592,25 +665,12 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void:
if water_root:
tile_root.add_child(water_root)
_set_editor_owner_recursive(water_root)
if enable_m2_assets:
_build_tile_m2_assets(
tile_root,
tile_origin,
data.get("m2_names", PackedStringArray()),
data.get("m2_placements", []))
if enable_m2_placeholders:
_build_tile_m2_placeholders(
tile_root,
tile_origin,
data.get("m2_names", PackedStringArray()),
data.get("m2_placements", []))
if enable_wmo:
_build_tile_wmos(
tile_root,
tile_origin,
data.get("wmo_names", PackedStringArray()),
data.get("wmo_placements", []))
var state := {
"key": key,
"tx": request["tx"],
@@ -629,11 +689,18 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void:
"desired_tile_lod": -1,
"wanted": true,
"root": tile_root,
"m2_names": data.get("m2_names", PackedStringArray()),
"m2_placements": data.get("m2_placements", []),
"m2_built": false,
"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 = _sync_detail_assets(state, _last_focus_pos)
_tile_states[key] = state
_rebuild_chunk_queues()
@@ -663,27 +730,14 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v
var tile_origin: Vector3 = baked_tile.get("tile_origin")
tile_root.position = tile_origin
if enable_m2_assets:
var baked_m2_names_value: Variant = baked_tile.get("m2_names")
var baked_m2_placements_value: Variant = baked_tile.get("m2_placements")
var baked_m2_names: PackedStringArray = baked_m2_names_value if baked_m2_names_value is PackedStringArray else PackedStringArray()
var baked_m2_placements: Array = baked_m2_placements_value if baked_m2_placements_value is Array else []
_build_tile_m2_assets(
tile_root,
tile_origin,
baked_m2_names,
baked_m2_placements)
if enable_wmo:
var baked_wmo_names_value: Variant = baked_tile.get("wmo_names")
var baked_wmo_placements_value: Variant = baked_tile.get("wmo_placements")
var baked_wmo_names: PackedStringArray = baked_wmo_names_value if baked_wmo_names_value is PackedStringArray else PackedStringArray()
var baked_wmo_placements: Array = baked_wmo_placements_value if baked_wmo_placements_value is Array else []
_build_tile_wmos(
tile_root,
tile_origin,
baked_wmo_names,
baked_wmo_placements)
var 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"],
@@ -702,9 +756,16 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v
"desired_tile_lod": -1,
"wanted": true,
"root": tile_root,
"m2_names": baked_m2_names,
"m2_placements": baked_m2_placements,
"m2_built": false,
"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 = _sync_detail_assets(state, _last_focus_pos)
_tile_states[key] = state
_rebuild_chunk_queues()
if debug_streaming:
@@ -910,6 +971,7 @@ func _release_tile(key: String) -> void:
return
var state: Dictionary = _tile_states[key]
_unregister_tile_wmos(state)
var tile_lod_rid: RID = state.get("tile_lod_rid", RID())
if tile_lod_rid.is_valid():
_free_render_instance(tile_lod_rid)
@@ -949,6 +1011,10 @@ func _clear_streamed_world() -> void:
_tile_states.clear()
_clear_tile_mesh_cache()
_clear_wmo_registry()
_ensure_world_wmo_root()
if enable_wmo:
_place_global_wmo()
func _reset_terrain_root_children() -> void:
@@ -1121,16 +1187,110 @@ func _tile_mesh_cache_key(tile_key: String, lod: int) -> String:
return "%s|%d" % [tile_key, lod]
func _build_tile_wmos(tile_root: Node3D, tile_origin: Vector3, wmo_names: PackedStringArray, wmo_placements: Array) -> void:
if wmo_names.is_empty() or wmo_placements.is_empty():
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
var wmo_root := Node3D.new()
wmo_root.name = "WMOs"
tile_root.add_child(wmo_root)
_set_editor_owner_recursive(wmo_root)
_ensure_world_wmo_root()
for placement_variant in wmo_placements:
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)
_apply_visibility_range_recursive(instance, wmo_visibility_range)
_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", [])
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, focus_pos, m2_tile_radius):
if not bool(state.get("m2_built", false)):
state["m2_built"] = _build_tile_m2_assets(
tile_root as Node3D,
state.get("origin", Vector3.ZERO),
state.get("m2_names", PackedStringArray()),
state.get("m2_placements", []))
else:
_remove_tile_m2_assets(state)
state["m2_built"] = false
return 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"]
for i in wmo_placements.size():
var placement_variant = wmo_placements[i]
if not (placement_variant is Dictionary):
continue
var placement: Dictionary = placement_variant
@@ -1138,17 +1298,83 @@ func _build_tile_wmos(tile_root: Node3D, tile_origin: Vector3, wmo_names: Packed
if name_id < 0 or name_id >= wmo_names.size():
continue
var unique_key := _wmo_unique_key(placement, tile_key, i)
if _wmo_registry.has(unique_key):
var entry: Dictionary = _wmo_registry[unique_key]
var entry_refs: Dictionary = entry["refs"]
entry_refs[tile_key] = true
refs.append(unique_key)
continue
var rel_path: String = str(wmo_names[name_id]).replace("\\", "/")
var instance := _instantiate_wmo(rel_path, placement, tile_origin)
var instance := _instantiate_wmo_world(rel_path, placement)
if instance == null:
continue
wmo_root.add_child(instance)
_world_wmo_root.add_child(instance)
_apply_visibility_range_recursive(instance, wmo_visibility_range)
_set_editor_owner_recursive(instance)
_wmo_registry[unique_key] = {
"node": instance,
"refs": {tile_key: true},
}
refs.append(unique_key)
state["wmo_refs"] = refs
func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> void:
if m2_names.is_empty() or m2_placements.is_empty():
func _unregister_tile_wmos(state: Dictionary) -> void:
var refs: Array = state.get("wmo_refs", [])
if refs.is_empty():
return
var tile_key: String = state["key"]
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():
var node: Node = entry.get("node", null)
if node:
node.queue_free()
_wmo_registry.erase(unique_key)
state["wmo_refs"] = []
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:
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):
node.queue_free()
_wmo_registry.clear()
if _world_wmo_root and is_instance_valid(_world_wmo_root):
_world_wmo_root.queue_free()
_world_wmo_root = null
func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> bool:
if m2_names.is_empty() or m2_placements.is_empty():
return false
var existing_m2_root := tile_root.get_node_or_null("M2s")
if existing_m2_root != null:
return not existing_m2_root.is_queued_for_deletion()
var groups: Dictionary = {}
for placement_variant in m2_placements:
@@ -1169,14 +1395,15 @@ func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: Pa
var pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
var scale_value: float = float(placement.get("scale", 1.0))
var xform := Transform3D(Basis.from_euler(rot).scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos)
var basis := Basis.from_euler(rot) * Basis(Vector3.UP, M2_RIGHT_YAW_OFFSET)
var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos)
if not groups.has(normalized):
groups[normalized] = []
(groups[normalized] as Array).append(xform)
if groups.is_empty():
return
return false
var m2_root := Node3D.new()
m2_root.name = "M2s"
@@ -1200,15 +1427,40 @@ func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: Pa
mmi.name = rel_path.get_file().get_basename()
mmi.multimesh = mm
mmi.cast_shadow = (
GeometryInstance3D.SHADOW_CASTING_SETTING_ON
if m2_cast_shadows
else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
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)
if m2_root.get_child_count() == 0:
m2_root.queue_free()
return false
return true
func _remove_tile_m2_assets(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 _get_or_load_m2_mesh(rel_path: String) -> Mesh:
@@ -1317,23 +1569,23 @@ func _instantiate_m2(rel_path: String, placement: Dictionary, tile_origin: Vecto
node.name = rel_path.get_file().get_basename()
node.position = placement.get("pos", Vector3.ZERO) - tile_origin
node.rotation = placement.get("rot", Vector3.ZERO)
node.rotation = placement.get("rot", Vector3.ZERO) + Vector3(0.0, M2_RIGHT_YAW_OFFSET, 0.0)
var scale_value: float = float(placement.get("scale", 1.0))
node.scale = Vector3.ONE * scale_value
return node
func _instantiate_wmo(rel_path: String, placement: Dictionary, tile_origin: Vector3) -> Node3D:
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()
instance.position = placement.get("pos", Vector3.ZERO) - tile_origin
var rot: Vector3 = placement.get("rot", Vector3.ZERO)
rot.y += PI
instance.rotation = rot
# 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
return instance