оптимизация рендера

This commit is contained in:
2026-06-25 17:10:07 +04:00
parent 8c6cd88e31
commit af5e1f4d6c
26 changed files with 2691 additions and 171 deletions
+243
View File
@@ -0,0 +1,243 @@
#include "adt_baker.h"
#include "blp_loader.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/array.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <algorithm>
#include <cmath>
using namespace godot;
namespace {
constexpr float FALLBACK_R = 0.4f;
constexpr float FALLBACK_G = 0.55f;
constexpr float FALLBACK_B = 0.3f;
static float clamp01(float v) {
return std::max(0.0f, std::min(1.0f, v));
}
static int32_t positive_mod(int32_t value, int32_t mod) {
int32_t result = value % mod;
return result < 0 ? result + mod : result;
}
static float wrap01(float v) {
return v - std::floor(v);
}
} // namespace
Ref<Image> ADTBaker::bake_tile_albedo(
const Dictionary &data,
Dictionary image_cache,
const String &extracted_dir,
int32_t texture_size) {
const int32_t size = get_baked_albedo_size(texture_size);
const int32_t chunk_pixels = size / 16;
if (size <= 0 || chunk_pixels <= 0) {
return Ref<Image>();
}
PackedByteArray out;
out.resize(size * size * 3);
uint8_t *out_ptr = out.ptrw();
for (int32_t i = 0; i < size * size; ++i) {
out_ptr[i * 3 + 0] = static_cast<uint8_t>(FALLBACK_R * 255.0f);
out_ptr[i * 3 + 1] = static_cast<uint8_t>(FALLBACK_G * 255.0f);
out_ptr[i * 3 + 2] = static_cast<uint8_t>(FALLBACK_B * 255.0f);
}
const PackedStringArray tex_names = data.get("textures", PackedStringArray());
const Array chunks = data.get("chunks", Array());
for (int64_t ci = 0; ci < chunks.size(); ++ci) {
const Dictionary chunk = chunks[ci];
if (chunk.is_empty()) {
continue;
}
const int32_t chunk_x = int32_t(chunk.get("index_x", -1));
const int32_t chunk_y = int32_t(chunk.get("index_y", -1));
if (chunk_x < 0 || chunk_x >= 16 || chunk_y < 0 || chunk_y >= 16) {
continue;
}
const Array layers = chunk.get("layers", Array());
if (layers.is_empty()) {
continue;
}
TextureData textures[4];
const int32_t layer_count = std::min<int32_t>(int32_t(layers.size()), 4);
for (int32_t li = 0; li < layer_count; ++li) {
const Dictionary layer = layers[li];
const int32_t tex_id = int32_t(layer.get("texture_id", -1));
if (tex_id >= 0 && tex_id < tex_names.size()) {
textures[li] = load_texture_data(tex_names[tex_id], image_cache, extracted_dir);
}
}
if (!textures[0].valid) {
continue;
}
const Array alpha_maps = chunk.get("alpha_maps", Array());
const int32_t base_x = chunk_x * chunk_pixels;
const int32_t base_y = chunk_y * chunk_pixels;
for (int32_t py = 0; py < chunk_pixels; ++py) {
const float local_v = (float(py) + 0.5f) / float(chunk_pixels);
const int32_t ay = std::max(0, std::min(63, int32_t(std::floor(local_v * 63.999f))));
for (int32_t px = 0; px < chunk_pixels; ++px) {
const float local_u = (float(px) + 0.5f) / float(chunk_pixels);
const int32_t ax = std::max(0, std::min(63, int32_t(std::floor(local_u * 63.999f))));
float weights[4] = {1.0f, 0.0f, 0.0f, 0.0f};
for (int32_t li = 1; li < layer_count; ++li) {
PackedByteArray alpha;
if (li - 1 < alpha_maps.size()) {
alpha = alpha_maps[li - 1];
}
if (alpha.size() == 64 * 64) {
weights[li] = float(alpha[ay * 64 + ax]) / 255.0f;
}
}
weights[0] = std::max(0.0f, 1.0f - weights[1] - weights[2] - weights[3]);
const float sum = weights[0] + weights[1] + weights[2] + weights[3];
if (sum > 0.0f) {
for (float &weight : weights) {
weight /= sum;
}
}
const float tiled_u = local_u * 8.0f;
const float tiled_v = local_v * 8.0f;
float rgb[3] = {0.0f, 0.0f, 0.0f};
for (int32_t li = 0; li < layer_count; ++li) {
if (!textures[li].valid || weights[li] <= 0.0f) {
continue;
}
float sample[3];
sample_image_repeat(textures[li], tiled_u, tiled_v, sample);
rgb[0] += sample[0] * weights[li];
rgb[1] += sample[1] * weights[li];
rgb[2] += sample[2] * weights[li];
}
const int32_t out_index = ((base_y + py) * size + (base_x + px)) * 3;
out_ptr[out_index + 0] = static_cast<uint8_t>(clamp01(rgb[0]) * 255.0f + 0.5f);
out_ptr[out_index + 1] = static_cast<uint8_t>(clamp01(rgb[1]) * 255.0f + 0.5f);
out_ptr[out_index + 2] = static_cast<uint8_t>(clamp01(rgb[2]) * 255.0f + 0.5f);
}
}
}
return Image::create_from_data(size, size, false, Image::FORMAT_RGB8, out);
}
int32_t ADTBaker::get_baked_albedo_size(int32_t texture_size) {
const int32_t safe_size = std::max(texture_size, 16);
const int32_t chunk_pixels = std::max(8, int32_t(std::ceil(float(safe_size) / 16.0f)));
return chunk_pixels * 16;
}
String ADTBaker::normalize_rel_path(const String &path) {
return path.replace("\\", "/");
}
ADTBaker::TextureData ADTBaker::load_texture_data(
const String &blp_path,
Dictionary &image_cache,
const String &extracted_dir) {
TextureData texture;
if (blp_path.is_empty()) {
return texture;
}
Ref<Image> image;
if (image_cache.has(blp_path)) {
image = image_cache[blp_path];
} else {
const String rel_path = normalize_rel_path(blp_path);
String abs_path = extracted_dir;
if (!abs_path.ends_with("/") && !abs_path.ends_with("\\")) {
abs_path += "/";
}
abs_path += rel_path;
Ref<BLPLoader> loader;
loader.instantiate();
image = loader->load_image(abs_path);
image_cache[blp_path] = image;
}
if (image.is_null() || image->is_empty()) {
return texture;
}
if (image->get_format() != Image::FORMAT_RGBA8) {
image = image->duplicate();
image->convert(Image::FORMAT_RGBA8);
}
texture.image = image;
texture.width = image->get_width();
texture.height = image->get_height();
texture.bytes = image->get_data();
texture.valid = texture.width > 0 && texture.height > 0 && texture.bytes.size() >= texture.width * texture.height * 4;
return texture;
}
void ADTBaker::sample_image_repeat(
const TextureData &texture,
float u,
float v,
float out_rgb[3]) {
if (!texture.valid) {
out_rgb[0] = 1.0f;
out_rgb[1] = 1.0f;
out_rgb[2] = 1.0f;
return;
}
const float fu = wrap01(u);
const float fv = wrap01(v);
const float px = fu * float(texture.width) - 0.5f;
const float py = fv * float(texture.height) - 0.5f;
const int32_t x0 = positive_mod(int32_t(std::floor(px)), texture.width);
const int32_t y0 = positive_mod(int32_t(std::floor(py)), texture.height);
const int32_t x1 = positive_mod(x0 + 1, texture.width);
const int32_t y1 = positive_mod(y0 + 1, texture.height);
const float tx = px - std::floor(px);
const float ty = py - std::floor(py);
const uint8_t *bytes = texture.bytes.ptr();
const int32_t i00 = (y0 * texture.width + x0) * 4;
const int32_t i10 = (y0 * texture.width + x1) * 4;
const int32_t i01 = (y1 * texture.width + x0) * 4;
const int32_t i11 = (y1 * texture.width + x1) * 4;
for (int32_t c = 0; c < 3; ++c) {
const float c00 = float(bytes[i00 + c]) / 255.0f;
const float c10 = float(bytes[i10 + c]) / 255.0f;
const float c01 = float(bytes[i01 + c]) / 255.0f;
const float c11 = float(bytes[i11 + c]) / 255.0f;
const float cx0 = c00 + (c10 - c00) * tx;
const float cx1 = c01 + (c11 - c01) * tx;
out_rgb[c] = cx0 + (cx1 - cx0) * ty;
}
}
void ADTBaker::_bind_methods() {
ClassDB::bind_method(
D_METHOD("bake_tile_albedo", "data", "image_cache", "extracted_dir", "texture_size"),
&ADTBaker::bake_tile_albedo);
}
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <godot_cpp/classes/image.hpp>
#include <godot_cpp/classes/ref_counted.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/packed_byte_array.hpp>
#include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/string.hpp>
namespace godot {
class ADTBaker : public RefCounted {
GDCLASS(ADTBaker, RefCounted)
public:
Ref<Image> bake_tile_albedo(
const Dictionary &data,
Dictionary image_cache,
const String &extracted_dir,
int32_t texture_size);
protected:
static void _bind_methods();
private:
struct TextureData {
Ref<Image> image;
PackedByteArray bytes;
int32_t width = 0;
int32_t height = 0;
bool valid = false;
};
static int32_t get_baked_albedo_size(int32_t texture_size);
static String normalize_rel_path(const String &path);
static TextureData load_texture_data(
const String &blp_path,
Dictionary &image_cache,
const String &extracted_dir);
static void sample_image_repeat(
const TextureData &texture,
float u,
float v,
float out_rgb[3]);
};
} // namespace godot
+2
View File
@@ -2,6 +2,7 @@
#include "mpq_manager.h"
#include "wmo_loader.h"
#include "adt_loader.h"
#include "adt_baker.h"
#include "blp_loader.h"
#include "m2_loader.h"
#include "wdt_loader.h"
@@ -16,6 +17,7 @@ void initialize_mpq_extractor_module(ModuleInitializationLevel p_level) {
ClassDB::register_class<MPQManager>();
ClassDB::register_class<WMOLoader>();
ClassDB::register_class<ADTLoader>();
ClassDB::register_class<ADTBaker>();
ClassDB::register_class<BLPLoader>();
ClassDB::register_class<M2Loader>();
ClassDB::register_class<WDTLoader>();
+2 -1
View File
@@ -1,2 +1,3 @@
*
!.gitignore
!.gitignore
!wmo_streaming_resource.gd
+13
View File
@@ -0,0 +1,13 @@
extends Resource
class_name WMOStreamingResource
const FORMAT_VERSION := 1
@export var format_version: int = FORMAT_VERSION
@export var source_path: String = ""
@export var mesh_names: PackedStringArray = PackedStringArray()
@export var meshes: Array[Mesh] = []
@export var mesh_transforms: Array[Transform3D] = []
@export var multimesh_names: PackedStringArray = PackedStringArray()
@export var multimeshes: Array[MultiMesh] = []
@export var multimesh_transforms: Array[Transform3D] = []
@@ -16,12 +16,29 @@ ambient_light_energy = 0.5
script = ExtResource("1_stream")
camera_path = NodePath("Camera3D")
update_interval = 0.1
max_concurrent_tile_tasks = 4
chunk_ops_per_tick = 64
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"
cached_tile_mesh_limit = 48
editor_follow_view_camera = false
wmo_render_cache_dir = "res://data/cache/wmo_render_v1"
enable_occlusion_culling = false
enable_water = true
terrain_cast_shadows = true
enable_wmo = true
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)
@@ -34,7 +51,6 @@ fast_mult = 8.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)
light_energy = 1.5
shadow_enabled = true
directional_shadow_mode = 1
directional_shadow_fade_start = 0.85
directional_shadow_max_distance = 2200.0
File diff suppressed because it is too large Load Diff
+17 -11
View File
@@ -2,7 +2,7 @@ extends SceneTree
## Usage:
## godot --headless --path <project> --script res://src/tools/bake_adt_terrain_cache.gd -- \
## --map Azeroth --jobs 4 --force
## --map Azeroth --jobs auto --force
##
## `--jobs N` starts N separate Godot worker processes. Process isolation keeps
## ResourceSaver/Image/loader state independent while using multiple CPU cores.
@@ -37,7 +37,7 @@ func _initialize() -> void:
_get_arg_value(
args,
"--full-texture-size",
str(legacy_texture_size if legacy_texture_size != null else 4196)
str(legacy_texture_size if legacy_texture_size != null else 2048)
).to_int()
)
var coarse_texture_size := maxi(
@@ -48,11 +48,12 @@ func _initialize() -> void:
str(legacy_texture_size if legacy_texture_size != null else 512)
).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 jobs := maxi(1, _get_arg_value(args, "--jobs", "1").to_int())
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:
@@ -127,18 +128,16 @@ func _initialize() -> void:
_progress_update(tx, ty, baked, skipped, failed)
continue
var full_payload: Dictionary = _builder.build_baked_tile_render_payload(
var payloads: Dictionary = _builder.build_baked_tile_render_payload_pair(
data,
_image_cache,
ProjectSettings.globalize_path(extracted_dir),
0,
full_texture_size)
var coarse_payload: Dictionary = _builder.build_baked_tile_render_payload(
data,
_image_cache,
ProjectSettings.globalize_path(extracted_dir),
full_texture_size,
3,
coarse_texture_size)
var full_payload: Dictionary = payloads.get("full", {})
var coarse_payload: Dictionary = payloads.get("coarse", {})
if full_payload.is_empty():
push_warning("Bake produced empty full mesh: %s" % source_res_path)
@@ -396,6 +395,13 @@ func _get_optional_int_arg(args: PackedStringArray, name: String):
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
+10 -4
View File
@@ -114,10 +114,16 @@ func _bake_terrain(map_name: String, map_dir: String, extracted: String,
if data.is_empty() or not data.has("chunks"):
failed += 1; continue
var full_payload := builder.build_baked_tile_render_payload(
data, _image_cache, ProjectSettings.globalize_path(extracted), 0, full_tex)
var coarse_payload := builder.build_baked_tile_render_payload(
data, _image_cache, ProjectSettings.globalize_path(extracted), 3, coarse_tex)
var payloads: Dictionary = builder.build_baked_tile_render_payload_pair(
data,
_image_cache,
ProjectSettings.globalize_path(extracted),
0,
full_tex,
3,
coarse_tex)
var full_payload: Dictionary = payloads.get("full", {})
var coarse_payload: Dictionary = payloads.get("coarse", {})
if full_payload.is_empty():
failed += 1; continue
+98
View File
@@ -0,0 +1,98 @@
extends SceneTree
## Converts the heavy baked ADT cache into a lightweight runtime streaming cache.
##
## Usage:
## godot --headless --path <project> --script res://src/tools/build_adt_streaming_cache.gd -- \
## --map Azeroth --input res://data/cache/baked_terrain_v2 --output res://data/cache/baked_terrain_stream_v1 --force
const BAKED_TILE_SCRIPT := preload("res://src/resources/baked_adt_tile.gd")
const STREAMING_TILE_SCRIPT := preload("res://src/resources/streaming_adt_tile.gd")
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var map_name := _arg(args, "--map", "Azeroth")
var input_dir := _res(_arg(args, "--input", "res://data/cache/baked_terrain_v2"))
var output_dir := _res(_arg(args, "--output", "res://data/cache/baked_terrain_stream_v1"))
var force := args.has("--force")
var in_map_dir := input_dir.path_join(map_name)
var out_map_dir := output_dir.path_join(map_name)
var in_map_abs := ProjectSettings.globalize_path(in_map_dir)
var out_map_abs := ProjectSettings.globalize_path(out_map_dir)
if not DirAccess.dir_exists_absolute(in_map_abs):
push_error("Input baked terrain cache not found: %s" % in_map_dir)
quit(1)
return
DirAccess.make_dir_recursive_absolute(out_map_abs)
var dir := DirAccess.open(in_map_dir)
if dir == null:
push_error("Cannot open input cache dir: %s" % in_map_dir)
quit(1)
return
var files := dir.get_files()
files.sort()
var converted := 0
var skipped := 0
var failed := 0
var started_ms := Time.get_ticks_msec()
for file_name in files:
if not file_name.ends_with(".res"):
continue
var in_path := in_map_dir.path_join(file_name)
var out_path := out_map_dir.path_join(file_name)
if not force and ResourceLoader.exists(out_path):
skipped += 1
continue
var baked: Resource = load(in_path)
if baked == null or baked.get_script() != BAKED_TILE_SCRIPT:
push_warning("Invalid baked tile: %s" % in_path)
failed += 1
continue
var stream_tile: Resource = STREAMING_TILE_SCRIPT.new()
stream_tile.set("format_version", STREAMING_TILE_SCRIPT.FORMAT_VERSION)
stream_tile.set("map_name", str(baked.get("map_name")))
stream_tile.set("tile_x", int(baked.get("tile_x")))
stream_tile.set("tile_y", int(baked.get("tile_y")))
stream_tile.set("tile_origin", baked.get("tile_origin"))
stream_tile.set("texture_size", int(baked.get("coarse_texture_size")))
stream_tile.set("wmo_names", baked.get("wmo_names"))
stream_tile.set("wmo_placements", baked.get("wmo_placements"))
stream_tile.set("m2_names", baked.get("m2_names"))
stream_tile.set("m2_placements", baked.get("m2_placements"))
stream_tile.set("terrain_mesh", baked.get("coarse_mesh"))
var err := ResourceSaver.save(stream_tile, out_path)
if err != OK:
push_warning("Failed to save streaming tile %s (err=%d)" % [out_path, err])
failed += 1
continue
converted += 1
if converted % 25 == 0:
print("Converted %d tiles..." % converted)
var elapsed_ms := Time.get_ticks_msec() - started_ms
print("Streaming cache finished. converted=%d skipped=%d failed=%d elapsed=%.2fs" % [
converted, skipped, failed, float(elapsed_ms) / 1000.0
])
quit(0 if failed == 0 else 2)
func _arg(args: PackedStringArray, name: String, default: String) -> String:
var idx := args.find(name)
if idx >= 0 and idx + 1 < args.size():
return args[idx + 1]
return default
func _res(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"):
return path
return "res://" + path.trim_prefix("./").trim_prefix("/")
@@ -0,0 +1 @@
uid://ctokf2tfwftl7
+182
View File
@@ -0,0 +1,182 @@
extends SceneTree
## Builds a lightweight WMO render cache for runtime streaming.
##
## Usage:
## godot --headless --path <project> --script res://src/tools/build_wmo_streaming_cache.gd -- \
## --map Azeroth --extracted res://data/extracted --output res://data/cache/wmo_render_v1 --force
const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd")
const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd")
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
var map_name := _arg(args, "--map", "Azeroth")
var extracted := _res(_arg(args, "--extracted", "res://data/extracted"))
var output_dir := _res(_arg(args, "--output", "res://data/cache/wmo_render_v1"))
var force := args.has("--force")
if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("WMOLoader"):
push_error("GDExtension not loaded. Rebuild first.")
quit(1)
return
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(output_dir))
var unique := _collect_unique_wmos(map_name, extracted)
if unique.is_empty():
push_error("No WMO references found for map: %s" % map_name)
quit(1)
return
var wmo_loader = ClassDB.instantiate("WMOLoader")
var baked := 0
var skipped := 0
var failed := 0
var total := unique.size()
var index := 0
var started_ms := Time.get_ticks_msec()
for rel_path_variant in unique.keys():
index += 1
var rel_path := String(rel_path_variant)
var out_path := _get_wmo_render_path(output_dir, rel_path)
if not force and ResourceLoader.exists(out_path):
skipped += 1
continue
var abs_wmo := ProjectSettings.globalize_path(extracted.path_join(rel_path))
if not FileAccess.file_exists(abs_wmo):
failed += 1
continue
var data: Dictionary = wmo_loader.call("load_wmo", abs_wmo)
if data.is_empty():
failed += 1
continue
var node: Node3D = WMO_BUILDER_SCRIPT.build(data, extracted)
if node == null:
failed += 1
continue
var render_resource: Resource = WMO_STREAMING_SCRIPT.new()
render_resource.set("format_version", WMO_STREAMING_SCRIPT.FORMAT_VERSION)
render_resource.set("source_path", rel_path)
_collect_render_payload(node, render_resource)
node.free()
if (render_resource.get("meshes") as Array).is_empty() and (render_resource.get("multimeshes") as Array).is_empty():
failed += 1
continue
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(out_path.get_base_dir()))
var err := ResourceSaver.save(render_resource, out_path)
if err != OK:
push_warning("save failed for %s: %d" % [out_path, err])
failed += 1
continue
baked += 1
if index % 10 == 0 or index == total:
print("[%d/%d] baked=%d skipped=%d failed=%d" % [index, total, baked, skipped, failed])
var elapsed_ms := Time.get_ticks_msec() - started_ms
print("WMO render cache finished. baked=%d skipped=%d failed=%d elapsed=%.2fs" % [
baked,
skipped,
failed,
float(elapsed_ms) / 1000.0,
])
WMO_BUILDER_SCRIPT.clear_caches()
quit(0 if failed == 0 else 2)
func _collect_unique_wmos(map_name: String, extracted: String) -> Dictionary:
var unique: Dictionary = {}
var adt_loader = ClassDB.instantiate("ADTLoader")
var map_dir := extracted.path_join("World/Maps/%s" % map_name)
var dir := DirAccess.open(map_dir)
if dir == null:
push_error("Cannot open map dir: %s" % map_dir)
return unique
print("Scanning ADT tiles...")
var scanned := 0
for file_name in dir.get_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
var data: Dictionary = adt_loader.call("load_adt",
ProjectSettings.globalize_path(map_dir.path_join(file_name)))
if data.is_empty():
continue
for rel in data.get("wmo_names", PackedStringArray()):
var normalized := str(rel).replace("\\", "/").to_lower()
if not normalized.is_empty():
unique[normalized] = true
scanned += 1
print("Found %d unique WMO models from %d tiles." % [unique.size(), scanned])
return unique
func _collect_render_payload(
node: Node,
render_resource: Resource,
parent_transform: Transform3D = Transform3D.IDENTITY) -> void:
if node.name == "Occluders":
return
var node_transform := parent_transform
if node is Node3D:
node_transform = parent_transform * (node as Node3D).transform
if node is MeshInstance3D:
var mesh_instance := node as MeshInstance3D
if mesh_instance.mesh != null:
var names: PackedStringArray = render_resource.get("mesh_names")
var meshes: Array = render_resource.get("meshes")
var transforms: Array = render_resource.get("mesh_transforms")
names.append(mesh_instance.name)
meshes.append(mesh_instance.mesh)
transforms.append(node_transform)
render_resource.set("mesh_names", names)
render_resource.set("meshes", meshes)
render_resource.set("mesh_transforms", transforms)
elif node is MultiMeshInstance3D:
var multimesh_instance := node as MultiMeshInstance3D
if multimesh_instance.multimesh != null:
var names: PackedStringArray = render_resource.get("multimesh_names")
var multimeshes: Array = render_resource.get("multimeshes")
var transforms: Array = render_resource.get("multimesh_transforms")
names.append(multimesh_instance.name)
multimeshes.append(multimesh_instance.multimesh)
transforms.append(node_transform)
render_resource.set("multimesh_names", names)
render_resource.set("multimeshes", multimeshes)
render_resource.set("multimesh_transforms", transforms)
for child in node.get_children():
_collect_render_payload(child, render_resource, node_transform)
func _get_wmo_render_path(output_dir: String, rel_path: String) -> String:
var normalized := rel_path.replace("\\", "/")
return output_dir.path_join(normalized.get_basename() + ".res")
func _arg(args: PackedStringArray, name: String, default: String) -> String:
var idx := args.find(name)
if idx >= 0 and idx + 1 < args.size():
return args[idx + 1]
return default
func _res(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"):
return path
return "res://" + path.trim_prefix("./").trim_prefix("/")
@@ -0,0 +1 @@
uid://d16p5f4t34jpy