новый рендер

This commit is contained in:
2026-06-27 12:13:11 +04:00
parent af5e1f4d6c
commit 1b8d15f2e4
16 changed files with 2562 additions and 443 deletions
+2
View File
@@ -1,3 +1,5 @@
*
!.gitignore
!wmo_streaming_resource.gd
!splat_adt_tile.gd
!control_splat_adt_tile.gd
+17
View File
@@ -0,0 +1,17 @@
extends Resource
class_name ControlSplatADTTile
const FORMAT_VERSION := 3
@export var format_version: int = FORMAT_VERSION
@export var map_name: String = ""
@export var tile_x: int = 0
@export var tile_y: int = 0
@export var tile_origin: Vector3 = Vector3.ZERO
@export var lod: int = 0
@export var texture_size: int = 256
@export var terrain_mesh: Mesh
@export var texture_images: Array[Image] = []
@export var alpha_atlas: Texture2D
@export var layer_index_map: Texture2D
@export var texture_names: PackedStringArray = PackedStringArray()
+12
View File
@@ -0,0 +1,12 @@
extends Resource
class_name SplatADTTile
const FORMAT_VERSION := 1
@export var format_version: int = FORMAT_VERSION
@export var map_name: String = ""
@export var tile_x: int = 0
@export var tile_y: int = 0
@export var tile_origin: Vector3 = Vector3.ZERO
@export var lod: int = 3
@export var terrain_mesh: Mesh
@@ -0,0 +1,218 @@
extends CharacterBody3D
const TILE_SIZE := 533.33333
const CHUNK_SIZE := TILE_SIZE / 16.0
const UNIT_SIZE := CHUNK_SIZE / 8.0
@export var extracted_dir: String = "res://data/extracted"
@export var map_name: String = "Azeroth"
@export var spawn_tile_x: int = 42
@export var spawn_tile_y: int = 28
@export var spawn_at_tile_center: bool = true
@export var run_speed: float = 7.0
@export var backward_speed: float = 4.5
@export var strafe_speed: float = 4.5
@export var sprint_multiplier: float = 6.0
@export var acceleration: float = 28.0
@export var mouse_sensitivity: float = 0.003
@export var camera_pitch_min: float = deg_to_rad(-65.0)
@export var camera_pitch_max: float = deg_to_rad(35.0)
@export var camera_height: float = 1.7
@export var camera_distance: float = 8.0
@export var ground_offset: float = 0.05
@export var ground_snap_speed: float = 24.0
@export var camera_pivot_path: NodePath = NodePath("CameraPivot")
@export var camera_path: NodePath = NodePath("CameraPivot/Camera3D")
@export var visual_path: NodePath = NodePath("Visual")
var _camera_pivot: Node3D
var _camera: Camera3D
var _visual: Node3D
var _captured := false
var _yaw := 0.0
var _pitch := deg_to_rad(-18.0)
var _horizontal_velocity := Vector3.ZERO
var _adt_cache: Dictionary = {}
func _ready() -> void:
_camera_pivot = get_node_or_null(camera_pivot_path) as Node3D
_camera = get_node_or_null(camera_path) as Camera3D
_visual = get_node_or_null(visual_path) as Node3D
if _camera:
_camera.current = true
_camera.far = 50000.0
if spawn_at_tile_center:
global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE
global_position.z = (float(spawn_tile_y) + 0.5) * TILE_SIZE
var ground := _sample_ground_height(global_position)
if is_finite(ground):
global_position.y = ground + ground_offset
_yaw = rotation.y
_apply_camera_transform()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
_captured = event.pressed
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if _captured and event is InputEventMouseMotion:
_yaw -= event.relative.x * mouse_sensitivity
_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, camera_pitch_min, camera_pitch_max)
rotation.y = _yaw
_apply_camera_transform()
func _physics_process(delta: float) -> void:
var input_dir := _get_input_dir()
var target_velocity := Vector3.ZERO
if input_dir.length_squared() > 0.0:
target_velocity = _movement_vector(input_dir)
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
global_position += _horizontal_velocity * delta
var ground := _sample_ground_height(global_position)
if is_finite(ground):
var target_y := ground + ground_offset
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
if _visual and _horizontal_velocity.length_squared() > 0.01:
_visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z)
_apply_camera_transform()
func _get_input_dir() -> Vector2:
var dir := Vector2.ZERO
if Input.is_key_pressed(KEY_W):
dir.y -= 1.0
if Input.is_key_pressed(KEY_S):
dir.y += 1.0
if Input.is_key_pressed(KEY_A):
dir.x -= 1.0
if Input.is_key_pressed(KEY_D):
dir.x += 1.0
return dir.normalized()
func _movement_vector(input_dir: Vector2) -> Vector3:
var forward := -global_basis.z
var right := global_basis.x
forward.y = 0.0
right.y = 0.0
forward = forward.normalized()
right = right.normalized()
var speed_z := run_speed if input_dir.y < 0.0 else backward_speed
var speed_x := strafe_speed
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint
func _apply_camera_transform() -> void:
if _camera_pivot:
_camera_pivot.position = Vector3(0.0, camera_height, 0.0)
_camera_pivot.rotation.x = _pitch
if _camera:
_camera.position = Vector3(0.0, 0.0, camera_distance)
_camera.rotation = Vector3.ZERO
func _sample_ground_height(world_pos: Vector3) -> float:
var tx := int(floor(world_pos.x / TILE_SIZE))
var ty := int(floor(world_pos.z / TILE_SIZE))
var data := _load_adt(tx, ty)
if data.is_empty():
return NAN
var tile_origin := _get_tile_origin(data)
var local := world_pos - tile_origin
var chunk_x := clampi(int(floor(local.x / CHUNK_SIZE)), 0, 15)
var chunk_y := clampi(int(floor(local.z / CHUNK_SIZE)), 0, 15)
var chunk := _find_chunk(data, chunk_x, chunk_y, tile_origin)
if chunk.is_empty():
return NAN
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var chunk_local := world_pos - origin
var gx := clampf(chunk_local.x / UNIT_SIZE, 0.0, 8.0)
var gz := clampf(chunk_local.z / UNIT_SIZE, 0.0, 8.0)
var x0 := clampi(int(floor(gx)), 0, 8)
var z0 := clampi(int(floor(gz)), 0, 8)
var x1 := mini(x0 + 1, 8)
var z1 := mini(z0 + 1, 8)
var fx := gx - float(x0)
var fz := gz - float(z0)
var heights: PackedFloat32Array = chunk.get("heights", PackedFloat32Array())
if heights.size() < 145:
return NAN
var h00 := heights[_outer(z0, x0)]
var h10 := heights[_outer(z0, x1)]
var h01 := heights[_outer(z1, x0)]
var h11 := heights[_outer(z1, x1)]
var hx0 := lerpf(h00, h10, fx)
var hx1 := lerpf(h01, h11, fx)
return origin.y + lerpf(hx0, hx1, fz)
func _load_adt(tx: int, ty: int) -> Dictionary:
var key := "%d_%d" % [tx, ty]
if _adt_cache.has(key):
return _adt_cache[key]
if not ClassDB.class_exists("ADTLoader"):
_adt_cache[key] = {}
return {}
var res_path := extracted_dir.path_join("World/Maps/%s/%s_%d_%d.adt" % [map_name, map_name, tx, ty])
var abs_path := ProjectSettings.globalize_path(res_path)
if not FileAccess.file_exists(abs_path):
_adt_cache[key] = {}
return {}
var loader = ClassDB.instantiate("ADTLoader")
var data: Dictionary = loader.call("load_adt", abs_path) if loader else {}
_adt_cache[key] = data
return data
func _find_chunk(data: Dictionary, chunk_x: int, chunk_y: int, tile_origin: Vector3) -> Dictionary:
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
var local := origin - tile_origin
var cx := clampi(int(round(local.x / CHUNK_SIZE)), 0, 15)
var cy := clampi(int(round(local.z / CHUNK_SIZE)), 0, 15)
if cx == chunk_x and cy == chunk_y:
return chunk
return {}
func _get_tile_origin(data: Dictionary) -> Vector3:
var found := false
var min_x := 0.0
var min_z := 0.0
for chunk in data.get("chunks", []) as Array:
if chunk.is_empty():
continue
var origin: Vector3 = chunk.get("origin", Vector3.ZERO)
if not found:
min_x = origin.x
min_z = origin.z
found = true
else:
min_x = minf(min_x, origin.x)
min_z = minf(min_z, origin.z)
return Vector3(min_x, 0.0, min_z) if found else Vector3.ZERO
func _outer(row: int, col: int) -> int:
return row * 17 + col
@@ -0,0 +1 @@
uid://ltn2ko5kxvo4
@@ -1,7 +1,7 @@
[gd_scene format=3 uid="uid://f1nqi4emji47"]
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"]
[ext_resource type="Script" uid="uid://bauggobg40psr" path="res://src/scenes/camera/fly_camera.gd" id="2_flycam"]
[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"]
[sub_resource type="Sky" id="Sky_1"]
@@ -12,41 +12,82 @@ ambient_light_source = 3
ambient_light_color = Color(1, 1, 1, 1)
ambient_light_energy = 0.5
[sub_resource type="StandardMaterial3D" id="PlayerMaterial_1"]
albedo_color = Color(0.25, 0.42, 0.85, 1)
roughness = 0.65
[sub_resource type="CapsuleMesh" id="PlayerCapsuleMesh_1"]
material = SubResource("PlayerMaterial_1")
radius = 0.45
height = 2.1
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
height = 2.1
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_stream")
camera_path = NodePath("Camera3D")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
quality_preset = "High"
update_interval = 0.1
tiles_per_tick = 1
max_concurrent_tile_tasks = 1
chunk_ops_per_tick = 16
tile_lod_remove_ops_per_tick = 1
tile_finalize_ops_per_tick = 1
detail_asset_ops_per_tick = 1
m2_build_groups_per_tick = 1
m2_multimesh_batch_size = 32
m2_mesh_finalize_ops_per_tick = 1
wmo_build_instances_per_tick = 1
wmo_render_group_ops_per_tick = 24
wmo_max_runtime_scene_mb = 8.0
prewarm_tile_margin = 2
retain_tile_margin = 3
boundary_prefetch_threshold = 0.4
streaming_tile_cache_dir = "res://data/cache/baked_terrain_stream_v1"
m2_multimesh_batch_size = 64
wmo_render_group_ops_per_tick = 16
cached_tile_mesh_limit = 48
wmo_render_cache_dir = "res://data/cache/wmo_render_v1"
terrain_quality_mesh_cache_limit = 128
lod0_radius_chunks = 10
lod1_radius_chunks = 20
lod2_radius_chunks = 40
lod2_tile_radius = 5
prewarm_tile_margin = 3
retain_tile_margin = 4
full_lod_prewarm_tiles = 1.5
boundary_prefetch_threshold = 0.42
auto_position_camera = false
enable_occlusion_culling = false
terrain_full_quality_radius_tiles = 5
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
enable_water = true
enable_wmo = true
m2_tile_radius = 3
wmo_tile_radius = 5
m2_visibility_range = 1600.0
wmo_visibility_range = 3600.0
terrain_quality_log_enabled = true
terrain_quality_log_interval = 1.0
hitch_profiler_enabled = true
hitch_profiler_threshold_ms = 20.0
[node name="Camera3D" type="Camera3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 0.707107, 0.707107, 0, -0.707107, 0.707107, 0, 300, 300)
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 22666, 80, 15200)
script = ExtResource("2_player")
spawn_tile_x = 42
spawn_tile_y = 28
sprint_multiplier = 6.0
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer"]
position = Vector3(0, 1.05, 0)
shape = SubResource("PlayerCapsuleShape_1")
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer"]
position = Vector3(0, 1.05, 0)
mesh = SubResource("PlayerCapsuleMesh_1")
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer"]
position = Vector3(0, 1.7, 0)
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot"]
position = Vector3(0, 0, 8)
current = true
far = 50000.0
script = ExtResource("2_flycam")
speed = 1200.0
fast_mult = 8.0
fov = 70.0
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=1436804627]
transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 0, 0)
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
extends SceneTree
## Builds ADT terrain resources using one Texture2DArray/control-map material per tile.
##
## Usage:
## godot --headless --path <project> --script res://src/tools/build_adt_control_splat_cache.gd -- \
## --map Azeroth --jobs auto --texture-size 256 --lod 0 --force
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
const CONTROL_SPLAT_TILE_SCRIPT := preload("res://src/resources/control_splat_adt_tile.gd")
var _builder
var _loader
var _image_cache: Dictionary = {}
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var map_name := _get_arg_value(args, "--map", "Azeroth")
var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted"))
var output_dir := _normalize_res_path(_get_arg_value(args, "--output", "res://data/cache/terrain_control_splat_v3"))
var lod := clampi(_get_arg_value(args, "--lod", "0").to_int(), 0, 3)
var texture_size := clampi(_get_arg_value(args, "--texture-size", "256").to_int(), 16, 1024)
var worker_index: Variant = _get_optional_int_arg(args, "--worker-index")
var worker_count := maxi(1, _get_arg_value(args, "--worker-count", "1").to_int())
var tile_x: Variant = _get_optional_int_arg(args, "--tile-x")
var tile_y: Variant = _get_optional_int_arg(args, "--tile-y")
var default_jobs := "1" if tile_x != null or tile_y != null else "auto"
var jobs := _parse_jobs_arg(_get_arg_value(args, "--jobs", default_jobs))
var force := args.has("--force")
if jobs > 1 and worker_index == null:
_run_parallel_controller(args, jobs)
return
_builder = ADT_BUILDER_SCRIPT.new()
if not ClassDB.class_exists("ADTLoader"):
push_error("ADTLoader not found. Rebuild GDExtension first.")
quit(1)
return
_loader = ClassDB.instantiate("ADTLoader")
if _loader == null:
push_error("Failed to instantiate ADTLoader.")
quit(1)
return
var map_dir := extracted_dir.path_join("World/Maps/%s" % map_name)
var dir := DirAccess.open(map_dir)
if dir == null:
push_error("Cannot open map directory: %s" % map_dir)
quit(1)
return
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir.path_join(map_name)))
var built := 0
var skipped := 0
var failed := 0
var tile_index := 0
var started_ms := Time.get_ticks_msec()
var files := dir.get_files()
var total := _count_worker_tiles(files, map_name, tile_x, tile_y, worker_index, worker_count)
print("Control splat cache started. map=%s tiles=%d lod=%d texture_size=%d" % [
map_name, total, lod, texture_size])
for file_name in 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()
if tile_x != null and tx != tile_x:
continue
if tile_y != null and ty != tile_y:
continue
if worker_index != null:
if tile_index % worker_count != int(worker_index):
tile_index += 1
continue
tile_index += 1
var output_res_path := output_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty])
var output_abs_path := ProjectSettings.globalize_path(output_res_path)
if not force and FileAccess.file_exists(output_abs_path):
skipped += 1
continue
var source_res_path := map_dir.path_join(file_name)
var source_abs_path := ProjectSettings.globalize_path(source_res_path)
var data: Dictionary = _loader.call("load_adt", source_abs_path)
if data.is_empty() or not data.has("chunks"):
push_warning("Control splat failed to parse ADT: %s" % source_res_path)
failed += 1
continue
var payload: Dictionary = _builder.build_control_splat_tile_render_payload(
data,
_image_cache,
ProjectSettings.globalize_path(extracted_dir),
lod,
texture_size)
var mesh: Mesh = payload.get("mesh", null)
var texture_images: Array = payload.get("texture_images", [])
if mesh == null or texture_images.is_empty():
push_warning("Control splat produced incomplete tile: %s" % source_res_path)
failed += 1
continue
var array_mesh := mesh as ArrayMesh
if array_mesh != null and array_mesh.get_surface_count() > 0:
array_mesh.surface_set_material(0, null)
var tile: Resource = CONTROL_SPLAT_TILE_SCRIPT.new()
tile.set("format_version", CONTROL_SPLAT_TILE_SCRIPT.FORMAT_VERSION)
tile.set("map_name", map_name)
tile.set("tile_x", tx)
tile.set("tile_y", ty)
tile.set("tile_origin", _builder.get_tile_origin_for_data(data))
tile.set("lod", lod)
tile.set("texture_size", texture_size)
tile.set("terrain_mesh", mesh)
tile.set("texture_images", texture_images)
tile.set("alpha_atlas", payload.get("alpha_atlas", null))
tile.set("layer_index_map", payload.get("layer_index_map", null))
tile.set("texture_names", payload.get("texture_names", PackedStringArray()))
var save_err := ResourceSaver.save(tile, output_res_path)
if save_err != OK:
push_warning("Failed to save control splat tile: %s (err=%d)" % [output_res_path, save_err])
failed += 1
continue
built += 1
if built % 25 == 0:
print("Built %d control splat tiles..." % built)
var elapsed_ms := Time.get_ticks_msec() - started_ms
print("Control splat cache finished. built=%d skipped=%d failed=%d elapsed=%.2fs" % [
built, skipped, failed, float(elapsed_ms) / 1000.0])
quit(0 if failed == 0 else 2)
func _run_parallel_controller(args: PackedStringArray, jobs: int) -> void:
var started_ms := Time.get_ticks_msec()
var threads: Array[Thread] = []
print("Starting parallel ADT control splat cache: jobs=%d" % jobs)
for worker_index in jobs:
var thread := Thread.new()
var err := thread.start(_run_worker_process.bind(args, worker_index, jobs))
if err != OK:
push_error("Failed to start control splat worker thread %d (err=%d)" % [worker_index, err])
quit(1)
return
threads.append(thread)
var failed := 0
for thread in threads:
var exit_code: int = int(thread.wait_to_finish())
if exit_code != 0:
failed += 1
var elapsed_ms := Time.get_ticks_msec() - started_ms
print("Parallel control splat cache finished. jobs=%d failed_workers=%d elapsed=%.2fs" % [
jobs, failed, float(elapsed_ms) / 1000.0])
quit(0 if failed == 0 else 2)
func _run_worker_process(args: PackedStringArray, worker_index: int, worker_count: int) -> int:
var worker_args := PackedStringArray([
"--headless",
"--path",
ProjectSettings.globalize_path("res://"),
"--script",
"res://src/tools/build_adt_control_splat_cache.gd",
"--",
])
var skip_next := false
for i in args.size():
if skip_next:
skip_next = false
continue
var arg := args[i]
if arg == "--jobs" or arg == "--worker-index" or arg == "--worker-count":
skip_next = i + 1 < args.size()
continue
worker_args.append(arg)
worker_args.append("--jobs")
worker_args.append("1")
worker_args.append("--worker-index")
worker_args.append(str(worker_index))
worker_args.append("--worker-count")
worker_args.append(str(worker_count))
print("Control splat worker %d/%d started" % [worker_index + 1, worker_count])
var exit_code := OS.execute(OS.get_executable_path(), worker_args)
print("Control splat worker %d/%d finished with exit=%d" % [worker_index + 1, worker_count, exit_code])
return exit_code
func _count_worker_tiles(
files: PackedStringArray,
map_name: String,
tile_x: Variant,
tile_y: Variant,
worker_index: Variant,
worker_count: int) -> int:
var tile_index := 0
var count := 0
for file_name in 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()
if tile_x != null and tx != tile_x:
continue
if tile_y != null and ty != tile_y:
continue
if worker_index != null and tile_index % worker_count != int(worker_index):
tile_index += 1
continue
tile_index += 1
count += 1
return count
func _parse_jobs_arg(value: String) -> int:
if value.to_lower() == "auto":
return maxi(1, OS.get_processor_count() - 1)
return maxi(1, value.to_int())
func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String:
var idx := args.find(name)
if idx >= 0 and idx + 1 < args.size():
return args[idx + 1]
return default_value
func _get_optional_int_arg(args: PackedStringArray, name: String) -> Variant:
var idx := args.find(name)
if idx >= 0 and idx + 1 < args.size() and args[idx + 1].is_valid_int():
return args[idx + 1].to_int()
return null
func _normalize_res_path(path: String) -> String:
return path.rstrip("/")
@@ -0,0 +1 @@
uid://c3vxox0uwyyx7
+377
View File
@@ -0,0 +1,377 @@
extends SceneTree
## Builds lightweight terrain-splat resources for near-camera quality.
##
## Usage:
## godot --headless --path <project> --script res://src/tools/build_adt_splat_cache.gd -- \
## --map Azeroth --jobs auto --lod 0 --force
const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd")
const SPLAT_TILE_SCRIPT := preload("res://src/resources/splat_adt_tile.gd")
const PROGRESS_DIR := "res://data/cache/splat_progress"
const PROGRESS_REPORT_INTERVAL_MS := 2000
var _builder
var _loader
var _tex_cache: Dictionary = {}
var _progress_path := ""
var _progress_worker_index := -1
var _progress_worker_count := 1
var _progress_total := 0
var _progress_processed := 0
var _progress_built := 0
var _progress_skipped := 0
var _progress_failed := 0
var _progress_last_tile := ""
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var map_name := _get_arg_value(args, "--map", "Azeroth")
var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted"))
var output_dir := _normalize_res_path(_get_arg_value(args, "--output", "res://data/cache/terrain_splat_v1"))
var lod := clampi(_get_arg_value(args, "--lod", "0").to_int(), 0, 3)
var worker_index: Variant = _get_optional_int_arg(args, "--worker-index")
var worker_count := maxi(1, _get_arg_value(args, "--worker-count", "1").to_int())
var tile_x: Variant = _get_optional_int_arg(args, "--tile-x")
var tile_y: Variant = _get_optional_int_arg(args, "--tile-y")
var default_jobs := "1" if tile_x != null or tile_y != null else "auto"
var jobs := _parse_jobs_arg(_get_arg_value(args, "--jobs", default_jobs))
var force := args.has("--force")
if jobs > 1 and worker_index == null:
_run_parallel_controller(args, jobs)
return
_builder = ADT_BUILDER_SCRIPT.new()
if not ClassDB.class_exists("ADTLoader"):
push_error("ADTLoader not found. Rebuild GDExtension first.")
quit(1)
return
_loader = ClassDB.instantiate("ADTLoader")
if _loader == null:
push_error("Failed to instantiate ADTLoader.")
quit(1)
return
var map_dir := extracted_dir.path_join("World/Maps/%s" % map_name)
var dir := DirAccess.open(map_dir)
if dir == null:
push_error("Cannot open map directory: %s" % map_dir)
quit(1)
return
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir.path_join(map_name)))
var built := 0
var skipped := 0
var failed := 0
var started_ms := Time.get_ticks_msec()
var tile_index := 0
var worker_total := _count_worker_tiles(dir.get_files(), map_name, tile_x, tile_y, worker_index, worker_count)
_progress_setup(worker_index, worker_count, worker_total)
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()
if tile_x != null and tx != tile_x:
continue
if tile_y != null and ty != tile_y:
continue
if worker_index != null:
if tile_index % worker_count != int(worker_index):
tile_index += 1
continue
tile_index += 1
var output_res_path := output_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty])
var output_abs_path := ProjectSettings.globalize_path(output_res_path)
if not force and FileAccess.file_exists(output_abs_path):
skipped += 1
_progress_update(tx, ty, built, skipped, failed)
continue
var source_res_path := map_dir.path_join(file_name)
var source_abs_path := ProjectSettings.globalize_path(source_res_path)
var data: Dictionary = _loader.call("load_adt", source_abs_path)
if data.is_empty() or not data.has("chunks"):
push_warning("Splat cache failed to parse ADT: %s" % source_res_path)
failed += 1
_progress_update(tx, ty, built, skipped, failed)
continue
var payload: Dictionary = _builder.build_tile_coarse_render_payload(
data,
_tex_cache,
ProjectSettings.globalize_path(extracted_dir),
lod)
var mesh: Mesh = payload.get("mesh", null)
if mesh == null:
push_warning("Splat cache produced empty mesh: %s" % source_res_path)
failed += 1
_progress_update(tx, ty, built, skipped, failed)
continue
var splat_tile: Resource = SPLAT_TILE_SCRIPT.new()
splat_tile.set("format_version", SPLAT_TILE_SCRIPT.FORMAT_VERSION)
splat_tile.set("map_name", map_name)
splat_tile.set("tile_x", tx)
splat_tile.set("tile_y", ty)
splat_tile.set("tile_origin", _builder.get_tile_origin_for_data(data))
splat_tile.set("lod", lod)
splat_tile.set("terrain_mesh", mesh)
var save_err := ResourceSaver.save(splat_tile, output_res_path)
if save_err != OK:
push_warning("Failed to save splat tile: %s (err=%d)" % [output_res_path, save_err])
failed += 1
_progress_update(tx, ty, built, skipped, failed)
continue
built += 1
_progress_update(tx, ty, built, skipped, failed)
if built % 25 == 0:
print("Built %d splat tiles..." % built)
var elapsed_ms := Time.get_ticks_msec() - started_ms
print("Splat cache finished. built=%d skipped=%d failed=%d elapsed=%.2fs" % [
built, skipped, failed, float(elapsed_ms) / 1000.0])
_progress_finish(built, skipped, failed)
quit(0 if failed == 0 else 2)
func _run_parallel_controller(args: PackedStringArray, jobs: int) -> void:
var started_ms := Time.get_ticks_msec()
var last_report_ms := 0
var threads: Array[Thread] = []
_prepare_progress_dir()
print("Starting parallel ADT splat cache: jobs=%d" % jobs)
for worker_index in jobs:
var thread := Thread.new()
var err := thread.start(_run_worker_process.bind(args, worker_index, jobs))
if err != OK:
push_error("Failed to start splat worker thread %d (err=%d)" % [worker_index, err])
quit(1)
return
threads.append(thread)
var finished := false
while not finished:
OS.delay_msec(250)
var now := Time.get_ticks_msec()
if now - last_report_ms >= PROGRESS_REPORT_INTERVAL_MS:
_print_parallel_progress(jobs, started_ms)
last_report_ms = now
finished = true
for thread in threads:
if thread.is_alive():
finished = false
break
var failed := 0
for thread in threads:
var exit_code: int = int(thread.wait_to_finish())
if exit_code != 0:
failed += 1
var elapsed_ms := Time.get_ticks_msec() - started_ms
_print_parallel_progress(jobs, started_ms)
print("Parallel splat cache finished. jobs=%d failed_workers=%d elapsed=%.2fs" % [
jobs, failed, float(elapsed_ms) / 1000.0])
quit(0 if failed == 0 else 2)
func _run_worker_process(args: PackedStringArray, worker_index: int, worker_count: int) -> int:
var worker_args := PackedStringArray([
"--headless",
"--path",
ProjectSettings.globalize_path("res://"),
"--script",
"res://src/tools/build_adt_splat_cache.gd",
"--",
])
var skip_next := false
for i in args.size():
if skip_next:
skip_next = false
continue
var arg := args[i]
if arg == "--jobs" or arg == "--worker-index" or arg == "--worker-count":
skip_next = i + 1 < args.size()
continue
worker_args.append(arg)
worker_args.append("--jobs")
worker_args.append("1")
worker_args.append("--worker-index")
worker_args.append(str(worker_index))
worker_args.append("--worker-count")
worker_args.append(str(worker_count))
print("Splat worker %d/%d started" % [worker_index + 1, worker_count])
var exit_code := OS.execute(OS.get_executable_path(), worker_args)
print("Splat worker %d/%d finished with exit=%d" % [worker_index + 1, worker_count, exit_code])
return exit_code
func _prepare_progress_dir() -> void:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(PROGRESS_DIR))
var dir := DirAccess.open(PROGRESS_DIR)
if dir == null:
return
for file_name in dir.get_files():
if file_name.begins_with("splat_worker_") and file_name.ends_with(".json"):
dir.remove(file_name)
func _progress_setup(worker_index: Variant, worker_count: int, total: int) -> void:
_progress_worker_index = int(worker_index) if worker_index != null else 0
_progress_worker_count = worker_count
_progress_total = total
_progress_processed = 0
_progress_built = 0
_progress_skipped = 0
_progress_failed = 0
_progress_last_tile = ""
_progress_path = PROGRESS_DIR.path_join("splat_worker_%d.json" % _progress_worker_index)
_progress_write(false)
func _progress_update(tx: int, ty: int, built: int, skipped: int, failed: int) -> void:
_progress_processed += 1
_progress_built = built
_progress_skipped = skipped
_progress_failed = failed
_progress_last_tile = "%d_%d" % [tx, ty]
_progress_write(false)
func _progress_finish(built: int, skipped: int, failed: int) -> void:
_progress_built = built
_progress_skipped = skipped
_progress_failed = failed
_progress_processed = _progress_total
_progress_write(true)
func _progress_write(done: bool) -> void:
if _progress_path.is_empty():
return
var file := FileAccess.open(_progress_path, FileAccess.WRITE)
if file == null:
return
file.store_string(JSON.stringify({
"worker": _progress_worker_index,
"workers": _progress_worker_count,
"total": _progress_total,
"processed": _progress_processed,
"built": _progress_built,
"skipped": _progress_skipped,
"failed": _progress_failed,
"last_tile": _progress_last_tile,
"done": done,
}))
func _print_parallel_progress(jobs: int, started_ms: int) -> void:
var now := Time.get_ticks_msec()
var total := 0
var processed := 0
var built := 0
var skipped := 0
var failed := 0
var done := 0
for worker_index in jobs:
var path := PROGRESS_DIR.path_join("splat_worker_%d.json" % worker_index)
if not FileAccess.file_exists(path):
continue
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
continue
var parsed: Variant = JSON.parse_string(file.get_as_text())
if not (parsed is Dictionary):
continue
var row: Dictionary = parsed
total += int(row.get("total", 0))
processed += int(row.get("processed", 0))
built += int(row.get("built", 0))
skipped += int(row.get("skipped", 0))
failed += int(row.get("failed", 0))
if bool(row.get("done", false)):
done += 1
var elapsed := maxf(0.001, float(now - started_ms) / 1000.0)
var rate := float(processed) / elapsed
print("Progress: %d/%d tiles, built=%d skipped=%d failed=%d, workers done=%d/%d, %.2f tiles/s" % [
processed, total, built, skipped, failed, done, jobs, rate])
func _count_worker_tiles(
files: PackedStringArray,
map_name: String,
tile_x: Variant,
tile_y: Variant,
worker_index: Variant,
worker_count: int) -> int:
var count := 0
var tile_index := 0
for file_name in files:
if not file_name.ends_with(".adt"):
continue
var parts := file_name.trim_suffix(".adt").split("_")
if parts.size() != 3 or parts[0] != map_name:
continue
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()
if tile_x != null and tx != tile_x:
continue
if tile_y != null and ty != tile_y:
continue
if worker_index != null and tile_index % worker_count != int(worker_index):
tile_index += 1
continue
tile_index += 1
count += 1
return count
func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String:
var index := args.find(name)
if index >= 0 and index + 1 < args.size():
return args[index + 1]
return default_value
func _get_optional_int_arg(args: PackedStringArray, name: String):
var index := args.find(name)
if index >= 0 and index + 1 < args.size() and args[index + 1].is_valid_int():
return args[index + 1].to_int()
return null
func _parse_jobs_arg(value: String) -> int:
var normalized := value.strip_edges().to_lower()
if normalized == "auto":
return clampi(OS.get_processor_count() - 2, 1, 8)
return maxi(1, normalized.to_int())
func _normalize_res_path(path: String) -> String:
if path.begins_with("res://"):
return path
return "res://%s" % path.trim_prefix("./").trim_prefix("/")
+1
View File
@@ -0,0 +1 @@
uid://cr7v1un5okd3p