diff --git a/RENDER.md b/RENDER.md new file mode 100644 index 0000000..41f3051 --- /dev/null +++ b/RENDER.md @@ -0,0 +1,546 @@ +# Renderer Notes + +Цель renderer-работы в этом проекте: добиться ощущения производительности оригинального клиента WoW 3.3.5a в Godot, без видимых фризов при переходе ADT -> ADT и без постоянного отката видимых участков к низкому качеству. + +Этот документ фиксирует текущее состояние рендера, сделанные оптимизации и практические правила дальнейшей работы. + +## Главный принцип + +Оригинальный клиент WoW выживал на слабых CPU за счет агрессивного сокращения CPU work: + +- мало draw calls; +- крупные батчи terrain; +- instancing/батчинг повторяющихся props; +- ленивое и заранее подготовленное подключение деталей; +- отдельные LOD/visibility решения для terrain, M2 и WMO; +- заранее подготовленные mipmaps и компактные texture formats. + +В Godot мы идем тем же путем: runtime не должен строить тяжелую геометрию, декодировать много текстур или инстанцировать большие сцены в момент, когда игрок пересекает границу ADT. + +## Основные файлы + +- `src/scenes/streaming/streaming_world_loader.gd` - главный runtime streamer мира. +- `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms. +- `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids. +- `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material. +- `addons/mpq_extractor/loaders/wmo_builder.gd` - сборка WMO group meshes, doodads и MLIQ liquids. +- `addons/mpq_extractor/loaders/wow_liquid_material.gd` - общий shader material для ADT/WMO жидкостей. +- `src/scenes/sky/wow_sky_controller.gd` - DBC-based outdoor lighting/sky controller. +- `src/scenes/player/third_person_wow_controller.gd` - тестовый third-person controller. +- `src/native/src/adt_loader.cpp` - ADT/MH2O parsing. +- `src/native/src/blp_loader.cpp` - BLP decoding. +- `src/native/src/m2_loader.cpp` / `.h` - M2/SKIN parsing. +- `src/native/src/wmo_loader.cpp` - WMO/MLIQ parsing. + +## Runtime Streaming + +`StreamingWorldLoader` строит мир вокруг focus/camera/player. Карта берется из `WDT`, поэтому для Azeroth загружается реальный список ADT tiles, например: + +```text +StreamingWorld: loaded 687 ADT tiles from WDT for Azeroth +``` + +Важные runtime очереди: + +- `tile` - загрузка/парсинг ADT tile. +- `terrainup` - upgrade terrain до полного качества. +- `terrainctl` - подключение control splat terrain cache. +- `terrainsplat` - legacy/high quality splat path, сейчас не основной путь. +- `detail` - запуск деталей tile. +- `m2task` - background grouping M2 placements. +- `m2mesh` - загрузка/финализация M2 mesh resource. +- `m2build` - создание MultiMesh групп. +- `wmobuild` - подключение WMO instances. +- `wmogroups` - подключение render-cache групп WMO. +- `lod+` / `lod-` - создание/удаление tile LOD. +- `chunk+` / `chunk-` - legacy chunk mesh ops. + +Фриз-профили пишутся строками вида: + +```text +HITCH 22.75ms refresh=false queues[...] terrainup=21.99 terrainq=0.28 m2build=0.08 +``` + +Главный смысл этих логов: если hitch больше порога, смотреть самый большой named section. Мы уже находили так bottlenecks: + +- `details=4000-5800ms` - тяжелая синхронная сборка деталей. +- `m2build=20-75ms` - слишком крупные MultiMesh записи за один frame. +- `wmobuild=300-1100ms` - тяжелое инстанцирование WMO `.tscn`. +- `terrainsplat=150-220ms` - runtime high-quality splat generation. +- `terrainup=20-45ms` - слишком дорогой upgrade terrain за frame. + +## Quality Presets And Budgets + +Главные настройки находятся в `streaming_world_loader.gd`: + +- `quality_preset`: `Performance`, `Balanced`, `High`, `Custom`. +- `tiles_per_tick` +- `max_concurrent_tile_tasks` +- `tile_finalize_ops_per_tick` +- `terrain_upgrade_finalize_ops_per_tick` +- `terrain_control_splat_cache_finalize_ops_per_tick` +- `m2_build_groups_per_tick` +- `m2_multimesh_batch_size` +- `m2_mesh_finalize_ops_per_tick` +- `wmo_build_instances_per_tick` +- `wmo_render_group_ops_per_tick` + +Практическое правило: если новая оптимизация возвращает hitch, сначала уменьшать per-frame budgets, а не отключать качество целиком. Видимое качество должно приходить заранее, вне поля зрения игрока. + +## Terrain Rendering + +### Старый подход + +Изначально terrain мог быть слишком мелко разбитым: + +- много chunk/node операций; +- отдельные материалы/меши; +- слабый контроль над моментом upgrade; +- низкое качество или мыло при возврате в уже посещенный участок. + +Это приводило к CPU overhead и видимому popping. + +### Текущий подход + +Основной путь качества сейчас - `ControlSplatADTTile`: + +- geometry хранится как готовый tile mesh; +- texture set собирается в `Texture2DArray`; +- alpha maps собираются в один atlas; +- layer ids хранятся в layer index map; +- material подключается через `ADTBuilder.apply_control_splat_material`; +- runtime должен только загрузить cache resource и применить material, а не пересобирать splat texture на лету. + +Кеш: + +```text +res://data/cache/terrain_control_splat_v3//__.res +``` + +Формат: + +```text +ControlSplatADTTile.FORMAT_VERSION = 3 +StreamingWorldLoader.REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION = 3 +``` + +Команда bake: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --script res://src/tools/build_adt_control_splat_cache.gd -- --map Azeroth --jobs auto --texture-size 256 --lod 0 --force +``` + +Важно: `--force` пересобирает cache. Не запускать его случайно, если задача не про bake. + +### Texture Quality + +Что было сделано: + +- перешли от baked albedo как основного visible quality к control splat; +- оставили baked/streaming terrain как fallback и warm path; +- добавили/используем full quality terrain радиус; +- добавили retention/cache для full-quality meshes, чтобы возврат в tile не сбрасывал видимый участок обратно в низкое качество; +- добавили логи `TERRAIN_QUALITY`, чтобы понимать, когда видимый набор tile действительно готов. + +Ключевые настройки: + +- `terrain_full_quality_enabled` +- `terrain_full_quality_radius_tiles` +- `terrain_control_splat_quality_enabled` +- `terrain_control_splat_primary` +- `terrain_control_splat_radius_tiles` +- `max_active_terrain_control_splat_tiles` +- `terrain_quality_mesh_cache_limit` +- `prewarm_tile_margin` +- `retain_tile_margin` +- `full_lod_prewarm_tiles` +- `boundary_prefetch_threshold` + +Правило для High: игрок не должен видеть low-res terrain под ногами или прямо перед собой. Low quality допустим только далеко или вне видимости. + +### Texture Seams + +Была проблема видимых стыков и неправильного повторения texture pattern между чанками. Исправление в shader control splat: + +- terrain texture sample теперь использует continuous `chunk_uv`, а не `local_uv`; +- alpha atlas sample clamp'ится внутри 64x64 chunk alpha cell; +- это уменьшает видимые seams на границах MCNK. + +Файл: `addons/mpq_extractor/loaders/adt_builder.gd`, `_get_control_splat_shader()`. + +## ADT Baking + +Были ускорены bake scripts. Главный рабочий bake для качества terrain: + +```text +src/tools/build_adt_control_splat_cache.gd +``` + +Что важно: + +- параллельные workers; +- cache resource на ADT tile; +- не пересобирать в runtime; +- incomplete tile считается ошибкой worker и выводится warning; +- cache лежит в `data/cache` и не должен попадать в git. + +Пример успешного результата: + +```text +Starting parallel ADT control splat cache: jobs=15 +Parallel control splat cache finished. jobs=15 failed_workers=0 elapsed=... +``` + +Если один worker падает: + +```text +WARNING: Control splat produced incomplete tile: ... +failed_workers=1 +``` + +Такой tile надо перезапечь отдельно или проверить исходный ADT. + +## M2 Rendering + +Цель: тысячи props не должны быть тысячами независимых Node3D/MeshInstance3D. + +Текущий подход: + +- ADT `MDDF` placements группируются по M2 path; +- для каждого M2 path создается один или несколько `MultiMeshInstance3D`; +- transforms пишутся батчами через `m2_multimesh_batch_size`; +- mesh/resource finalization ограничен `m2_mesh_finalize_ops_per_tick`; +- build групп ограничен `m2_build_groups_per_tick`. + +Это убрало большие `m2build` фризы, где раньше один frame мог тратить 20-75ms только на M2. + +Кеш: + +```text +res://data/cache/m2_glb +``` + +Скрипты: + +```text +src/tools/bake_m2_cache.gd +src/tools/collect_world_m2_list.gd +src/tools/m2_to_gltf.py +``` + +Что улучшали: + +- M2 parser читает дополнительные material/texture/batch данные; +- BLP alpha fixes улучшили прозрачность листвы/декора; +- `M2Builder` улучшен для texture/material setup; +- runtime M2 строится через MultiMesh, а не как дерево отдельных props. + +Ограничения: + +- M2 animation/skinning пока не полноценные; +- particles/ribbons не реализованы; +- WoW material flags не полностью совпадают; +- billboards/leaf bones пока не как в клиенте. + +## WMO Rendering + +Цель: WMO не должны фризить сцену при догрузке, особенно большие здания/города. + +Что сделано: + +- `WMOLoader` читает root/group WMO данные и MLIQ; +- `WMOBuilder` строит group meshes; +- runtime registry дедуплицирует WMO по `unique_id`; +- WMO на границе ADT не дублируются; +- большие WMO не должны инстанцироваться как тяжелые `.tscn` во время движения; +- добавлен lightweight render-cache path для WMO groups; +- `wmo_render_group_ops_per_tick` ограничивает подключение WMO groups по кадрам; +- `wmo_max_runtime_scene_mb` защищает от тяжелого runtime `.tscn` instantiate. + +Кеши: + +```text +res://data/cache/wmo_tscn +res://data/cache/wmo_render_v1 +``` + +Скрипты: + +```text +src/tools/bake_wmo_cache.gd +src/tools/build_wmo_streaming_cache.gd +``` + +Что улучшали по качеству: + +- включен texture repeat для WMO materials; +- улучшены BLP alpha/texture decode проблемы; +- WMO liquid mesh получает нормали/UV; +- WMO liquids используют общий `WowLiquidMaterial`. + +Ограничения: + +- portal/room based visibility WMO пока не повторяет клиент; +- occluders не являются полноценной заменой WoW portal culling; +- часть WMO material flags еще требует сверки с клиентом. + +## Liquids + +Open-realm не дал готовую реализацию water shader: в его docs liquid rendering отмечен как future pass. Поэтому добавлен собственный легкий Godot shader. + +Файл: + +```text +addons/mpq_extractor/loaders/wow_liquid_material.gd +``` + +Подключение: + +- ADT `MH2O` через `ADTBuilder.build_tile_water_scene`; +- WMO `MLIQ` через `WMOBuilder._build_group_liquid`. + +Что делает shader: + +- один общий `ShaderMaterial` cache для ADT/WMO; +- world-space animated ripples; +- fresnel highlight; +- отдельные цвета/параметры для water, magma, slime; +- без screen texture/refraction, чтобы не раздувать GPU cost; +- shadows у liquid mesh выключены. + +Почему так: + +- дешево; +- не грузит дополнительные textures; +- не создает D3D12 descriptor pressure; +- дает рабочую визуальную воду уже сейчас. + +Ограничения: + +- нет настоящего depth fade; +- нет shoreline foam/blending; +- LiquidType.dbc texture selection пока не используется; +- waterfalls/flowing water требуют отдельного M2/material path. + +## Sky And Lighting + +Добавлен `WowSkyController`: + +```text +src/scenes/sky/wow_sky_controller.gd +``` + +Что он делает: + +- читает DBC данные для light profiles; +- определяет текущую area/zone по позиции; +- выбирает lighting/fog profile; +- логирует `SKY_LIGHT`; +- поддерживает LightSkybox model loading; +- двигает skybox model за камерой/player. + +Пример лога: + +```text +SKY_LIGHT time=13.00 map=0 area=279:... zone=36:... profiles=[...] skybox=0 fog=614..31556 density=0.500 +SKYBOX_MODEL id=... path=... +``` + +Ограничения: + +- зона/skybox transitions еще требуют ручной сверки с клиентом; +- не все outdoor skybox models используются на всех локациях; +- lighting пока приближение, а не 1:1 с fixed-function клиентом. + +## Shadows + +Цель по производительности сейчас важнее полного совпадения dynamic shadows. + +Текущее правило: + +- terrain shadows выключены; +- M2 shadows выключены; +- WMO shadows выключены; +- liquid shadows выключены. + +Настройки: + +```text +terrain_cast_shadows +m2_cast_shadows +wmo_cast_shadows +``` + +Почему: + +- old WoW 3.3.5a использовал очень дешевую модель теней; +- полноценные Godot realtime shadows на большом мире быстро создают лишний GPU/CPU cost; +- сначала нужна стабильная streaming performance, затем можно добавлять selective blob/projected shadows. + +Для будущего: если брать идеи Cataclysm, делать не full dynamic shadows everywhere, а ограниченные character/near-object shadows с жестким distance budget. + +## Third Person Controller + +Добавлен тестовый контроллер для оценки рендера "с земли": + +```text +src/scenes/player/third_person_wow_controller.gd +``` + +Зачем: + +- свободная камера плохо показывает реальные проблемы игрока; +- нужен WoW-like темп перемещения; +- sprint нужен для стресс-теста ADT boundary streaming. + +Модель сейчас не важна, используется простая capsule. + +## Texture/BLP Work + +Что сделано: + +- BLP loader улучшен для alpha handling; +- texture mipmaps генерируются для runtime ImageTexture там, где texture создается из decoded BLP; +- для WMO/M2 материалов включается anisotropic/mipmap filtering там, где texture подключается; +- включен repeat для WMO материалов. + +Важная проблема Godot/D3D12: + +```text +Cannot create uniform set because there's not enough room in the RESOURCES descriptor heap. +Please increase rendering/rendering_device/d3d12/max_resource_descriptors. +``` + +Это возникает, когда renderer создает слишком много уникальных texture/uniform resources. Наши меры: + +- кешировать materials/textures; +- не создавать уникальный heavy shader/material на каждый chunk; +- использовать `Texture2DArray` для terrain control splat; +- ограничивать активные high-quality terrain tiles; +- избегать screen/refraction textures для water. + +Если ошибка возвращается, увеличивать project setting `rendering/rendering_device/d3d12/max_resource_descriptors` и одновременно искать, где плодятся уникальные материалы. + +## Caches + +Активные cache directories: + +```text +res://data/cache/baked_terrain_v2 +res://data/cache/baked_terrain_stream_v1 +res://data/cache/terrain_control_splat_v3 +res://data/cache/terrain_splat_v1 +res://data/cache/m2_glb +res://data/cache/wmo_tscn +res://data/cache/wmo_render_v1 +``` + +Правила: + +- cache не коммитить; +- при изменении resource format bump'ать `FORMAT_VERSION`; +- при изменении shader/material без изменения cached resource иногда достаточно restart scene; +- при изменении baked geometry/resource payload нужен rebake; +- WMO render cache нужен для больших WMO, иначе будут `wmobuild` фризы. + +Версии: + +```text +Baked terrain required: 4 +Splat terrain required: 1 +Control splat required: 3 +WMO streaming resource: 1 +``` + +## Debug Logs + +Важные логи: + +```text +HITCH ... +PERF ... +TERRAIN_QUALITY ... +SKY_LIGHT ... +SKYBOX_MODEL ... +``` + +Как читать `HITCH`: + +- если большой `terrainup` - full-quality terrain подключается слишком тяжело; +- если большой `terrainctl` - control splat cache финализация слишком дорогая; +- если большой `terrainsplat` - runtime splat path не должен быть включен для обычной игры; +- если большой `m2mesh` - mesh resource finalization надо сильнее дробить; +- если большой `m2build` - уменьшить `m2_build_groups_per_tick` или `m2_multimesh_batch_size`; +- если большой `wmobuild` - использовать render cache, уменьшить `wmo_build_instances_per_tick`; +- если большой `wmogroups` - уменьшить `wmo_render_group_ops_per_tick`. + +## Reference Findings + +По open-realm: + +- полезен как документация форматов/пайплайна; +- terrain/ADT/WMO/M2 идеи можно сверять; +- полноценный liquid rendering там не реализован; +- skybox/liquid не стоит напрямую переносить как готовый код. + +По WoW 3.3.5a: + +- старый клиент не рендерил все как modern physically based renderer; +- он агрессивно экономил CPU; +- основная цель для нас - frame pacing и отсутствие hitch, а не дорогие эффекты. + +## Commands + +Headless smoke: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --quit +``` + +Run scene headless: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . src/scenes/streaming/eastern_kingdoms_streaming.tscn --quit +``` + +Build native GDExtension: + +```powershell +cd src\native +.\build.bat Release +``` + +Build ADT control splat cache: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --script res://src/tools/build_adt_control_splat_cache.gd -- --map Azeroth --jobs auto --texture-size 256 --lod 0 --force +``` + +Build WMO render cache: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --script res://src/tools/build_wmo_streaming_cache.gd -- --map Azeroth +``` + +## Current Known Gaps + +- M2 animation/skinning. +- M2 particles/ribbons. +- Full WoW material flag parity. +- True WMO portal/room culling. +- Selective cheap dynamic shadows. +- Liquid depth fade/shore blending. +- LiquidType.dbc based water textures. +- Better skybox transition validation per zone. +- Automated renderer regression scenes/screenshots. + +## Practical Rule For Future Work + +If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. + +High quality must appear before the player arrives, not after crossing the ADT boundary. diff --git a/addons/mpq_extractor/bin/~libmpq_extractor.windows.x86_64.dll~RF1d8533eb.TMP b/addons/mpq_extractor/bin/~libmpq_extractor.windows.x86_64.dll~RF1d8533eb.TMP new file mode 100644 index 0000000..32783d9 Binary files /dev/null and b/addons/mpq_extractor/bin/~libmpq_extractor.windows.x86_64.dll~RF1d8533eb.TMP differ diff --git a/addons/mpq_extractor/loaders/adt_builder.gd b/addons/mpq_extractor/loaders/adt_builder.gd index 4647338..87b8ecb 100644 --- a/addons/mpq_extractor/loaders/adt_builder.gd +++ b/addons/mpq_extractor/loaders/adt_builder.gd @@ -2,6 +2,8 @@ extends RefCounted class_name ADTBuilder +const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd") + const TILE_SIZE := 533.33333 const CHUNK_SIZE := 33.33333 const UNIT_SIZE := CHUNK_SIZE / 8.0 @@ -18,7 +20,6 @@ static var _single_texture_material_cache: Dictionary = {} static var _baked_texture_material_cache: Dictionary = {} static var _alpha_texture_cache: Dictionary = {} static var _layered_material_cache: Dictionary = {} -static var _liquid_material_cache: Dictionary = {} ## Build a Node3D with 256 terrain chunk meshes. ## `extracted_dir` must be an absolute path to the extracted/ folder so BLPs can be loaded. @@ -780,6 +781,7 @@ static func _build_tile_water_root(data: Dictionary, origin_offset: Vector3) -> var mi := MeshInstance3D.new() mi.mesh = mesh mi.name = "Liquid_%d" % int(liquid_id) + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF root.add_child(mi) return root if root.get_child_count() > 0 else null @@ -817,6 +819,10 @@ static func _append_liquid_geometry( var y10 := liquid_heights[z * 9 + x + 1] - origin_offset.y var y01 := liquid_heights[(z + 1) * 9 + x] - origin_offset.y var y11 := liquid_heights[(z + 1) * 9 + x + 1] - origin_offset.y + var u0 := x0 / TILE_SIZE + var u1 := x1 / TILE_SIZE + var v0 := z0 / TILE_SIZE + var v1 := z1 / TILE_SIZE verts.append(Vector3(x0, y00, z0)) verts.append(Vector3(x0, y01, z1)) @@ -828,10 +834,10 @@ static func _append_liquid_geometry( nrms.append(Vector3.UP) nrms.append(Vector3.UP) - uvs_arr.append(Vector2(float(x) / 8.0, float(z) / 8.0)) - uvs_arr.append(Vector2(float(x) / 8.0, float(z + 1) / 8.0)) - uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z + 1) / 8.0)) - uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z) / 8.0)) + uvs_arr.append(Vector2(u0, v0)) + uvs_arr.append(Vector2(u0, v1)) + uvs_arr.append(Vector2(u1, v1)) + uvs_arr.append(Vector2(u1, v0)) indices.append(base) indices.append(base + 1) @@ -912,6 +918,7 @@ static func _build_liquid_mesh( var mi := MeshInstance3D.new() mi.mesh = mesh mi.position = chunk_origin - origin_offset + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF mi.name = "Liquid_%d_%d_%d" % [ chunk.get("index_x", 0), chunk.get("index_y", 0), @@ -938,33 +945,8 @@ static func _build_chunk_material( return _build_fallback_material() -static func _build_liquid_material(liquid_id: int) -> StandardMaterial3D: - if _liquid_material_cache.has(liquid_id): - return _liquid_material_cache[liquid_id] - - var color := _get_liquid_color(liquid_id) - var mat := StandardMaterial3D.new() - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.albedo_color = color - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - mat.roughness = 0.08 - mat.metallic = 0.0 - mat.emission_enabled = true - mat.emission = Color(color.r, color.g, color.b) * 0.08 - _liquid_material_cache[liquid_id] = mat - return mat - - -static func _get_liquid_color(liquid_id: int) -> Color: - match liquid_id: - 2, 14: - return Color(0.08, 0.20, 0.34, 0.62) - 3, 15, 19: - return Color(0.75, 0.28, 0.03, 0.72) - 4, 20: - return Color(0.24, 0.45, 0.14, 0.68) - _: - return Color(0.12, 0.33, 0.50, 0.58) +static func _build_liquid_material(liquid_id: int) -> Material: + return WOW_LIQUID_MATERIAL.build(liquid_id) static func _build_terrain_material( @@ -1287,18 +1269,20 @@ uniform float ambient = 0.62; uniform float diffuse = 0.38; uniform float hemi = 0.12; -vec3 sample_layer(float layer, vec2 local_uv) { - return texture(terrain_tex, vec3(local_uv * uv_scale, layer), mip_bias).rgb; +vec3 sample_layer(float layer, vec2 continuous_chunk_uv) { + return texture(terrain_tex, vec3(continuous_chunk_uv * uv_scale, layer), mip_bias).rgb; } void fragment() { vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999)); - vec2 chunk_f = floor(tile_uv * 16.0); - vec2 local_uv = fract(tile_uv * 16.0); + vec2 chunk_uv = tile_uv * 16.0; + vec2 chunk_f = floor(chunk_uv); + vec2 local_uv = chunk_uv - chunk_f; vec4 packed_ids = COLOR; vec4 layers = floor(packed_ids * 255.0 + vec4(0.5)); - vec3 alpha = texture(alpha_atlas, (chunk_f * 64.0 + local_uv * 64.0 + vec2(0.5)) / 1024.0).rgb; + vec2 alpha_px = clamp(local_uv * 64.0, vec2(0.5), vec2(63.5)); + vec3 alpha = texture(alpha_atlas, (chunk_f * 64.0 + alpha_px) / 1024.0).rgb; float w1 = alpha.r; float w2 = alpha.g; @@ -1311,10 +1295,10 @@ void fragment() { w3 /= sum; vec3 albedo = - sample_layer(layers.x, local_uv) * w0 + - sample_layer(layers.y, local_uv) * w1 + - sample_layer(layers.z, local_uv) * w2 + - sample_layer(layers.w, local_uv) * w3; + sample_layer(layers.x, chunk_uv) * w0 + + sample_layer(layers.y, chunk_uv) * w1 + + sample_layer(layers.z, chunk_uv) * w2 + + sample_layer(layers.w, chunk_uv) * w3; vec3 n = normalize(NORMAL); if (!FRONT_FACING) { diff --git a/addons/mpq_extractor/loaders/m2_builder.gd b/addons/mpq_extractor/loaders/m2_builder.gd index 6d9baa9..14190fd 100644 --- a/addons/mpq_extractor/loaders/m2_builder.gd +++ b/addons/mpq_extractor/loaders/m2_builder.gd @@ -21,8 +21,11 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: return root var textures: PackedStringArray = data.get("textures", PackedStringArray()) + var texture_types: PackedInt32Array = data.get("texture_types", PackedInt32Array()) + var texture_flags: PackedInt32Array = data.get("texture_flags", PackedInt32Array()) var materials: Array = data.get("materials", []) var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array()) + var model_path: String = str(data.get("model_path", "")) var mesh := ArrayMesh.new() @@ -55,7 +58,17 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: var surf_idx := mesh.get_surface_count() - 1 var mat_def: Dictionary = materials[mat_id] if mat_id >= 0 and mat_id < materials.size() else {} - mesh.surface_set_material(surf_idx, _build_material(mat_def, tc_idx, textures, tex_combos, extracted_dir)) + mesh.surface_set_material( + surf_idx, + _build_material( + mat_def, + tc_idx, + textures, + texture_types, + texture_flags, + tex_combos, + extracted_dir, + model_path)) if mesh.get_surface_count() == 0: return root @@ -71,36 +84,91 @@ static func _build_material( mat_def: Dictionary, texture_combo_index: int, textures: PackedStringArray, + texture_types: PackedInt32Array, + texture_flags: PackedInt32Array, tex_combos: PackedInt32Array, - extracted_dir: String) -> StandardMaterial3D: + extracted_dir: String, + model_path: String) -> StandardMaterial3D: var mat := StandardMaterial3D.new() - mat.cull_mode = BaseMaterial3D.CULL_DISABLED + var flags: int = int(mat_def.get("flags", 0)) + mat.cull_mode = BaseMaterial3D.CULL_DISABLED if (flags & 0x04) != 0 else BaseMaterial3D.CULL_BACK mat.roughness = 0.85 mat.metallic = 0.0 - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED if (flags & 0x01) != 0 else BaseMaterial3D.SHADING_MODE_PER_PIXEL mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED mat.vertex_color_use_as_albedo = false + mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, false) var blend_mode: int = mat_def.get("blend_mode", 0) match blend_mode: - 0: mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED - 1: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR - _: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + 0: + mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED + 1: + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR + mat.alpha_scissor_threshold = 0.5 + 3, 4, 6: + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY + _: + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY if texture_combo_index >= 0 and texture_combo_index < tex_combos.size(): var tex_idx: int = tex_combos[texture_combo_index] if tex_idx >= 0 and tex_idx < textures.size(): var tex_path: String = str(textures[tex_idx]).replace("\\", "/") + var tex_type := int(texture_types[tex_idx]) if tex_idx < texture_types.size() else 0 + var tex_flags := int(texture_flags[tex_idx]) if tex_idx < texture_flags.size() else 0 + if tex_path.is_empty(): + tex_path = _default_texture_path(model_path, tex_type) if not tex_path.is_empty(): var tex := _load_texture(tex_path, extracted_dir) if tex: mat.albedo_texture = tex mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC + mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, (tex_flags & 0x03) != 0) return mat +static func _default_texture_path(model_path: String, texture_type: int) -> String: + var normalized := model_path.replace("\\", "/") + var rel_model := _relative_extracted_model_path(normalized) + var rel_lower := rel_model.to_lower() + if rel_lower.begins_with("item/objectcomponents/weapon/") and texture_type == 2: + var stem := rel_model.get_file().get_basename() + if stem.to_lower() == "axe_1h_horde_a_01": + return "Item/ObjectComponents/Weapon/Axe_1H_Horde_A_01Gray.blp" + if not stem.is_empty(): + return "Item/ObjectComponents/Weapon/%s.blp" % stem + if rel_lower == "creature/wolf/wolf.m2" or rel_lower == "creature/wolf/wolf.mdx": + if texture_type == 11: + return "Creature/Wolf/WolfSkinCoyote.blp" + if texture_type == 12: + return "Creature/Wolf/WolfSkinCoyoteAlpha.blp" + if rel_lower == "creature/boar/boar.m2" or rel_lower == "creature/boar/boar.mdx": + if texture_type == 11: + return "Creature/Boar/BoarSkinIvory.blp" + if rel_lower == "creature/kobold/kobold.m2" or rel_lower == "creature/kobold/kobold.mdx": + if texture_type == 11: + return "Creature/Kobold/koboldskinAlbino.blp" + if rel_lower == "creature/murloc/murloc.m2" or rel_lower == "creature/murloc/murloc.mdx": + if texture_type == 11: + return "Creature/Murloc/SahauginskinBlue.blp" + return "" + + +static func _relative_extracted_model_path(path: String) -> String: + var marker := "/data/extracted/" + var lower := path.to_lower() + var marker_pos := lower.find(marker) + if marker_pos >= 0: + return path.substr(marker_pos + marker.length()) + return path + + static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D: if rel_path.is_empty() or extracted_dir.is_empty(): return null diff --git a/addons/mpq_extractor/loaders/wmo_builder.gd b/addons/mpq_extractor/loaders/wmo_builder.gd index 43147b8..751c988 100644 --- a/addons/mpq_extractor/loaders/wmo_builder.gd +++ b/addons/mpq_extractor/loaders/wmo_builder.gd @@ -6,6 +6,7 @@ class_name WMOBuilder const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") +const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd") const M2_RIGHT_YAW_OFFSET := PI * 0.5 const BUILD_OCCLUDERS := false const OCCLUDER_MIN_TRIANGLES := 16 @@ -27,7 +28,7 @@ static func clear_caches() -> void: _m2_prototype_cache.clear() _m2_missing_cache.clear() _texture_cache.clear() - _wmo_liquid_material_cache.clear() + WOW_LIQUID_MATERIAL.clear_cache() # Returns a Node3D containing one MeshInstance3D per WMO group. static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: @@ -339,6 +340,7 @@ static func _build_material( mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED mat.vertex_color_use_as_albedo = false + mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, true) var blend_mode: int = mat_def.get("blend_mode", 0) match blend_mode: @@ -356,6 +358,7 @@ static func _build_material( if tex: mat.albedo_texture = tex mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC + mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, true) mat.set_meta("wow_flags", mat_def.get("flags", 0)) mat.set_meta("wow_shader", mat_def.get("shader", 0)) @@ -363,7 +366,6 @@ static func _build_material( return mat -static var _wmo_liquid_material_cache: Dictionary = {} # WMO MLIQ → MeshInstance3D in WMO-local coordinates. # Corner & vertex grid are in WMO-local (Z-up); convert to Godot Y-up using the @@ -384,6 +386,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D: return null var verts := PackedVector3Array() + var nrms := PackedVector3Array() + var uvs := PackedVector2Array() var indices := PackedInt32Array() # Pre-compute Godot-space position for each grid vertex. @@ -417,6 +421,14 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D: verts.append(pos[i10]) verts.append(pos[i11]) verts.append(pos[i01]) + nrms.append(Vector3.UP) + nrms.append(Vector3.UP) + nrms.append(Vector3.UP) + nrms.append(Vector3.UP) + uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles)))) + uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles)))) + uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles)))) + uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles)))) # Two triangles, winding chosen to face up after the (-y, z, -x) flip. indices.append(base + 0) indices.append(base + 1) @@ -431,6 +443,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D: var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = verts + arrays[Mesh.ARRAY_NORMAL] = nrms + arrays[Mesh.ARRAY_TEX_UV] = uvs arrays[Mesh.ARRAY_INDEX] = indices var mesh := ArrayMesh.new() @@ -443,19 +457,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D: return mi -static func _wmo_liquid_material(material_id: int) -> StandardMaterial3D: - if _wmo_liquid_material_cache.has(material_id): - return _wmo_liquid_material_cache[material_id] - - var mat := StandardMaterial3D.new() - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA - mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED - # Default: bluish water. Without DBC liquid_type lookup we can't pick magma/slime - # precisely yet — material_id from MLIQ is the WMO MOMT slot, not the DBC id. - mat.albedo_color = Color(0.2, 0.4, 0.7, 0.6) - _wmo_liquid_material_cache[material_id] = mat - return mat +static func _wmo_liquid_material(material_id: int) -> Material: + return WOW_LIQUID_MATERIAL.build_wmo(material_id) static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D: diff --git a/addons/mpq_extractor/loaders/wow_liquid_material.gd b/addons/mpq_extractor/loaders/wow_liquid_material.gd new file mode 100644 index 0000000..c67e94c --- /dev/null +++ b/addons/mpq_extractor/loaders/wow_liquid_material.gd @@ -0,0 +1,177 @@ +extends RefCounted +class_name WowLiquidMaterial + +static var _shader: Shader +static var _cache: Dictionary = {} + + +static func clear_cache() -> void: + _cache.clear() + + +static func build(liquid_id: int) -> ShaderMaterial: + var key := "adt:%d" % liquid_id + if _cache.has(key): + return _cache[key] + + var style := _style_for_liquid(liquid_id) + var mat := _create_material(style) + _cache[key] = mat + return mat + + +static func build_wmo(material_id: int) -> ShaderMaterial: + var key := "wmo:%d" % material_id + if _cache.has(key): + return _cache[key] + + var style := { + "color": Color(0.10, 0.30, 0.48, 0.60), + "foam": Color(0.58, 0.80, 1.00, 0.22), + "wave_strength": 0.045, + "wave_scale": 0.095, + "wave_speed": 0.38, + "fresnel_power": 2.2, + "emission_strength": 0.025, + "magma_mode": 0.0, + } + var mat := _create_material(style) + _cache[key] = mat + return mat + + +static func _create_material(style: Dictionary) -> ShaderMaterial: + var mat := ShaderMaterial.new() + mat.shader = _get_shader() + mat.render_priority = 1 + mat.set_shader_parameter("liquid_color", style["color"]) + mat.set_shader_parameter("foam_color", style["foam"]) + mat.set_shader_parameter("wave_strength", float(style["wave_strength"])) + mat.set_shader_parameter("wave_scale", float(style["wave_scale"])) + mat.set_shader_parameter("wave_speed", float(style["wave_speed"])) + mat.set_shader_parameter("alpha_base", (style["color"] as Color).a) + mat.set_shader_parameter("fresnel_power", float(style["fresnel_power"])) + mat.set_shader_parameter("emission_strength", float(style["emission_strength"])) + mat.set_shader_parameter("magma_mode", float(style["magma_mode"])) + return mat + + +static func _style_for_liquid(liquid_id: int) -> Dictionary: + match liquid_id: + 3, 15, 19: + return { + "color": Color(0.78, 0.22, 0.03, 0.78), + "foam": Color(1.00, 0.66, 0.10, 0.38), + "wave_strength": 0.025, + "wave_scale": 0.12, + "wave_speed": 0.22, + "fresnel_power": 1.7, + "emission_strength": 0.42, + "magma_mode": 1.0, + } + 4, 20: + return { + "color": Color(0.16, 0.38, 0.12, 0.66), + "foam": Color(0.50, 0.80, 0.32, 0.24), + "wave_strength": 0.035, + "wave_scale": 0.11, + "wave_speed": 0.28, + "fresnel_power": 2.0, + "emission_strength": 0.05, + "magma_mode": 0.0, + } + 2, 14: + return { + "color": Color(0.05, 0.16, 0.30, 0.64), + "foam": Color(0.48, 0.72, 1.00, 0.20), + "wave_strength": 0.060, + "wave_scale": 0.075, + "wave_speed": 0.32, + "fresnel_power": 2.5, + "emission_strength": 0.025, + "magma_mode": 0.0, + } + _: + return { + "color": Color(0.08, 0.28, 0.46, 0.58), + "foam": Color(0.58, 0.82, 1.00, 0.24), + "wave_strength": 0.050, + "wave_scale": 0.085, + "wave_speed": 0.36, + "fresnel_power": 2.3, + "emission_strength": 0.025, + "magma_mode": 0.0, + } + + +static func _get_shader() -> Shader: + if _shader: + return _shader + + _shader = Shader.new() + _shader.code = """ +shader_type spatial; +render_mode blend_mix, cull_disabled, unshaded; + +uniform vec4 liquid_color : source_color = vec4(0.08, 0.28, 0.46, 0.58); +uniform vec4 foam_color : source_color = vec4(0.58, 0.82, 1.00, 0.24); +uniform float wave_strength = 0.05; +uniform float wave_scale = 0.085; +uniform float wave_speed = 0.36; +uniform float alpha_base = 0.58; +uniform float fresnel_power = 2.3; +uniform float emission_strength = 0.025; +uniform float magma_mode = 0.0; + +varying vec3 world_pos; + +float hash21(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); +} + +float noise21(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +void vertex() { + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + float t = TIME * wave_speed; + float wave_a = sin(world_pos.x * wave_scale + t); + float wave_b = cos(world_pos.z * wave_scale * 1.37 - t * 1.21); + VERTEX.y += (wave_a + wave_b) * wave_strength; +} + +void fragment() { + vec3 n = normalize(NORMAL); + vec3 v = normalize(VIEW); + float facing = clamp(abs(dot(n, v)), 0.0, 1.0); + float fresnel = pow(1.0 - facing, fresnel_power); + + vec2 p = world_pos.xz * wave_scale; + float n1 = noise21(p * 0.70 + vec2(TIME * wave_speed, -TIME * wave_speed * 0.60)); + float n2 = noise21(p * 1.80 + vec2(-TIME * wave_speed * 0.35, TIME * wave_speed * 0.45)); + float ripple = smoothstep(0.52, 0.86, n1 * 0.65 + n2 * 0.35); + + vec3 color = mix(liquid_color.rgb, foam_color.rgb, ripple * foam_color.a); + color += fresnel * foam_color.rgb * 0.35; + + if (magma_mode > 0.5) { + float glow = smoothstep(0.45, 1.0, ripple + fresnel * 0.45); + color += vec3(1.0, 0.35, 0.05) * glow * 0.45; + EMISSION = color * max(emission_strength, 0.35); + } else { + EMISSION = color * emission_strength; + } + + ALBEDO = color; + ALPHA = clamp(alpha_base + fresnel * 0.18 + ripple * 0.06, 0.0, 1.0); +} +""" + return _shader diff --git a/addons/mpq_extractor/loaders/wow_liquid_material.gd.uid b/addons/mpq_extractor/loaders/wow_liquid_material.gd.uid new file mode 100644 index 0000000..4f2ca8b --- /dev/null +++ b/addons/mpq_extractor/loaders/wow_liquid_material.gd.uid @@ -0,0 +1 @@ +uid://d10ykrmo5h0ih diff --git a/src/native/src/blp_loader.cpp b/src/native/src/blp_loader.cpp index 41d160d..430292c 100644 --- a/src/native/src/blp_loader.cpp +++ b/src/native/src/blp_loader.cpp @@ -180,6 +180,7 @@ Ref BLPLoader::parse(const uint8_t *data, size_t len) { const size_t palette_ofs = sizeof(BLP2Header); if (palette_ofs + 256*4 > len) return Ref(); const uint8_t *palette = data + palette_ofs; + const uint8_t *alpha = mip + (size_t)w * (size_t)h; rgba.resize(w * h * 4); for (uint32_t i = 0; i < w * h && i < mip_size; ++i) { @@ -187,7 +188,23 @@ Ref BLPLoader::parse(const uint8_t *data, size_t len) { rgba[i*4+0] = palette[idx*4+2]; // R (from BGR) rgba[i*4+1] = palette[idx*4+1]; // G rgba[i*4+2] = palette[idx*4+0]; // B - rgba[i*4+3] = (hdr.alphaDepth == 0) ? 255 : mip[w*h + i]; + uint8_t a = 255; + if (hdr.alphaDepth == 8) { + size_t alpha_pos = (size_t)w * (size_t)h + i; + a = alpha_pos < mip_size ? mip[alpha_pos] : 255; + } else if (hdr.alphaDepth == 4) { + size_t byte_pos = i >> 1; + if ((size_t)w * (size_t)h + byte_pos < mip_size) { + uint8_t nibble = (i & 1) ? (alpha[byte_pos] >> 4) : (alpha[byte_pos] & 0x0F); + a = (uint8_t)(nibble * 17); + } + } else if (hdr.alphaDepth == 1) { + size_t byte_pos = i >> 3; + if ((size_t)w * (size_t)h + byte_pos < mip_size) { + a = (alpha[byte_pos] & (1 << (i & 7))) ? 255 : 0; + } + } + rgba[i*4+3] = a; } } else if (hdr.encoding == 3) { diff --git a/src/native/src/m2_loader.cpp b/src/native/src/m2_loader.cpp index 2ddcf2a..8d325ba 100644 --- a/src/native/src/m2_loader.cpp +++ b/src/native/src/m2_loader.cpp @@ -199,6 +199,8 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string // ── Textures ───────────────────────────────────────────────────────────── PackedStringArray textures; + PackedInt32Array texture_types; + PackedInt32Array texture_flags; const auto *tex_arr = safe_array(buf, hdr.ofsTextures, hdr.nTextures); if (tex_arr) { for (uint32_t i = 0; i < hdr.nTextures; ++i) { @@ -217,6 +219,8 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string } } textures.push_back(to_godot(fname)); + texture_types.push_back((int)t.type); + texture_flags.push_back((int)t.flags); } } @@ -313,8 +317,11 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string Dictionary result; result["textures"] = textures; + result["texture_types"] = texture_types; + result["texture_flags"] = texture_flags; result["materials"] = materials; result["texture_combos"] = texture_combos; + result["model_path"] = to_godot(path); result["vertices"] = vertices; result["normals"] = normals; result["uvs"] = uvs; diff --git a/src/native/src/m2_loader.h b/src/native/src/m2_loader.h index 941581a..21ec411 100644 --- a/src/native/src/m2_loader.h +++ b/src/native/src/m2_loader.h @@ -24,8 +24,11 @@ namespace godot { // Return Dictionary: // { // "textures": PackedStringArray, # .blp texture paths (may be empty for replaceable) +// "texture_types": PackedInt32Array, # M2Texture.type per texture slot +// "texture_flags": PackedInt32Array, # M2Texture.flags per texture slot // "materials": Array[Dictionary], # [{flags, blend_mode}] // "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures +// "model_path": String, # absolute source .m2 path // "vertices": PackedVector3Array, # Godot-space positions // "normals": PackedVector3Array, // "uvs": PackedVector2Array, diff --git a/src/native/src/wmo_loader.cpp b/src/native/src/wmo_loader.cpp index 1a2cee2..98a6b9f 100644 --- a/src/native/src/wmo_loader.cpp +++ b/src/native/src/wmo_loader.cpp @@ -313,7 +313,7 @@ Dictionary WMOLoader::parse_group(const std::vector &buf) { auto *v = sc.array(); uvs.resize(n); for (uint32_t i = 0; i < n; ++i) - uvs[i] = Vector2(v[i].u, 1.0f - v[i].v); + uvs[i] = Vector2(v[i].u, v[i].v); } else if (sc.is("MOVI")) { uint32_t n = sc.size / 2; diff --git a/src/scenes/sky/wow_sky_controller.gd b/src/scenes/sky/wow_sky_controller.gd new file mode 100644 index 0000000..e1a0a89 --- /dev/null +++ b/src/scenes/sky/wow_sky_controller.gd @@ -0,0 +1,868 @@ +## DBC-driven outdoor sky controller for WoW 3.3.5a data. +extends Node + +const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") + +const TILE_SIZE := 533.33333 +const WOW_WORLD_CENTER := 17066.666 +const LIGHT_COORD_SCALE := 36.0 +const HALF_MINUTES_PER_DAY := 2880 + +const CH_AMBIENT := 0 +const CH_DIFFUSE := 1 +const CH_SKY_TOP := 2 +const CH_SKY_MIDDLE := 3 +const CH_SKY_BAND_1 := 4 +const CH_SKY_BAND_2 := 5 +const CH_FOG := 6 + +const FOG_END := 0 +const FOG_START_SCALAR := 1 +const CLOUD_DENSITY := 2 +const FOG_DENSITY := 3 + +const AREA_LIGHT_PARAMS_BY_ZONE := { + # Eastern Kingdoms outdoor fallback profiles for places not covered by Light.dbc volumes. + 1: 28, # Dun Morogh + 3: 40, # Badlands + 4: 40, # Blasted Lands + 8: 40, # Swamp of Sorrows + 10: 92, # Duskwood + 11: 34, # Wetlands + 12: 28, # Elwynn Forest + 28: 92, # Western Plaguelands + 33: 118, # Stranglethorn Vale + 36: 34, # Alterac Mountains + 38: 28, # Loch Modan + 40: 28, # Westfall + 41: 92, # Deadwind Pass + 44: 28, # Redridge Mountains + 45: 28, # Arathi Highlands + 46: 40, # Burning Steppes + 47: 118, # Hinterlands + 51: 40, # Searing Gorge + 85: 92, # Tirisfal Glades + 130: 92, # Silverpine Forest + 139: 92, # Eastern Plaguelands + 267: 28, # Hillsbrad Foothills + 1519: 28, # Stormwind + 1537: 28, # Ironforge + 1497: 92, # Undercity + 2365: 12, # Great Sea +} + +@export var extracted_dir: String = "res://data/extracted" +@export var map_name: String = "Azeroth" +@export var map_id: int = 0 +@export var target_path: NodePath +@export var world_environment_path: NodePath +@export var sun_path: NodePath + +@export var update_interval: float = 0.2 +@export var use_system_time: bool = false +@export_range(0.0, 24.0, 0.1) var fixed_time_hours: float = 13.0 +@export var time_speed: float = 0.0 +@export var smooth_speed: float = 6.0 +@export var debug_log_enabled: bool = true +@export var debug_log_interval: float = 5.0 +@export var skybox_models_enabled: bool = true +@export var skybox_model_scale: float = 1.0 +@export var skybox_model_height_offset: float = 0.0 +## Non-zero forces a LightSkybox.dbc ID for visual testing on maps that do not use outdoor skybox models. +@export var debug_force_skybox_id: int = 0 + +var _world_environment: WorldEnvironment +var _environment: Environment +var _sky_material: ProceduralSkyMaterial +var _sun: DirectionalLight3D +var _target: Node3D + +var _light_volumes_by_map: Dictionary = {} +var _default_light_params_by_map: Dictionary = {} +var _profiles: Dictionary = {} +var _light_skyboxes: Dictionary = {} +var _missing_skyboxes: Dictionary = {} +var _area_table: Dictionary = {} +var _adt_area_cache: Dictionary = {} +var _loaded := false +var _elapsed := 0.0 +var _debug_elapsed := 0.0 + +var _current: Dictionary = {} +var _skybox_root: Node3D +var _active_skybox_id := -1 +var _active_skybox_node: Node3D +var _last_logged_area_id := -1 +var _last_logged_zone_id := -1 +var _last_logged_profile_signature := "" + + +func _ready() -> void: + _resolve_nodes() + _prepare_environment() + _loaded = _load_lighting_dbcs() + if _loaded: + print("WowSkyController: loaded %d LightParams profiles, %d area records and %d %s light volumes" % [ + _profiles.size(), + _area_table.size(), + _get_light_volume_count(map_id), + map_name]) + else: + push_warning("WowSkyController: DBC lighting not loaded, using fallback sky") + _apply_sky(1.0) + _update_skybox_model() + _update_skybox_transform() + + +func _process(delta: float) -> void: + if not _environment or not _sky_material: + return + _elapsed += delta + _debug_elapsed += delta + fixed_time_hours = fposmod(fixed_time_hours + delta * time_speed, 24.0) + if _elapsed < update_interval: + _apply_sky(delta) + _update_skybox_transform() + return + _elapsed = 0.0 + _sample_current_params() + _apply_sky(delta) + _update_skybox_model() + _update_skybox_transform() + + +func _resolve_nodes() -> void: + if not world_environment_path.is_empty(): + _world_environment = get_node_or_null(world_environment_path) as WorldEnvironment + else: + _world_environment = get_parent().get_node_or_null("WorldEnvironment") as WorldEnvironment + if not sun_path.is_empty(): + _sun = get_node_or_null(sun_path) as DirectionalLight3D + else: + _sun = get_parent().get_node_or_null("Sun") as DirectionalLight3D + if not target_path.is_empty(): + _target = get_node_or_null(target_path) as Node3D + _skybox_root = Node3D.new() + _skybox_root.name = "SkyboxModelRoot" + _skybox_root.top_level = true + add_child(_skybox_root) + + +func _prepare_environment() -> void: + if not _world_environment: + push_warning("WowSkyController: WorldEnvironment not found") + return + + _environment = _world_environment.environment + if not _environment: + _environment = Environment.new() + else: + _environment = _environment.duplicate(true) + _world_environment.environment = _environment + + _environment.background_mode = Environment.BG_SKY + _environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY + _environment.reflected_light_source = Environment.REFLECTION_SOURCE_SKY + _environment.fog_enabled = true + _environment.fog_mode = Environment.FOG_MODE_EXPONENTIAL + + var sky := _environment.sky + if not sky: + sky = Sky.new() + _environment.sky = sky + + _sky_material = sky.sky_material as ProceduralSkyMaterial + if not _sky_material: + _sky_material = ProceduralSkyMaterial.new() + sky.sky_material = _sky_material + _sky_material.use_debanding = true + sky.radiance_size = Sky.RADIANCE_SIZE_256 + + +func _sample_current_params() -> void: + var time_hours := _get_time_hours() + var time_half := int(floor(fposmod(time_hours, 24.0) * 120.0)) % HALF_MINUTES_PER_DAY + var wow_pos := _get_target_wow_position() + var area_id := _get_target_area_id() + var zone_id := _get_zone_area_id(area_id) + var selected := _select_light_params(wow_pos, area_id, zone_id) + var params := _sample_blended_params(selected, time_half) + params["time_hours"] = time_hours + params["light_params"] = selected + params["skybox_id"] = _select_skybox_id(selected) + params["area_id"] = area_id + params["zone_id"] = zone_id + _current = params + + var profile_signature := str(selected) + if debug_log_enabled and ( + _debug_elapsed >= debug_log_interval + or area_id != _last_logged_area_id + or zone_id != _last_logged_zone_id + or profile_signature != _last_logged_profile_signature + ): + _debug_elapsed = 0.0 + _last_logged_area_id = area_id + _last_logged_zone_id = zone_id + _last_logged_profile_signature = profile_signature + print("SKY_LIGHT time=%.2f map=%d area=%d:%s zone=%d:%s wow=(%.1f,%.1f,%.1f) profiles=%s skybox=%s fog=%.0f..%.0f density=%.3f" % [ + time_hours, + map_id, + area_id, + _get_area_name(area_id), + zone_id, + _get_area_name(zone_id), + wow_pos.x, + wow_pos.y, + wow_pos.z, + str(selected), + str(params["skybox_id"]), + float(params.get("fog_start", 0.0)), + float(params.get("fog_end", 0.0)), + float(params.get("fog_density", 0.0))]) + + +func _apply_sky(delta: float) -> void: + if _current.is_empty(): + _sample_current_params() + + var blend := 1.0 + if delta > 0.0 and smooth_speed > 0.0: + blend = clamp(delta * smooth_speed, 0.0, 1.0) + + var sky_top: Color = _current.get("sky_top", Color(0.13, 0.32, 0.62)) + var sky_mid: Color = _current.get("sky_middle", Color(0.36, 0.58, 0.86)) + var sky_horizon: Color = _current.get("sky_band_1", Color(0.62, 0.76, 0.9)) + var sky_low: Color = _current.get("sky_band_2", sky_horizon) + var fog_color: Color = _current.get("fog_color", Color(0.55, 0.66, 0.72)) + var ambient: Color = _current.get("ambient", Color(0.72, 0.8, 0.88)) + var diffuse: Color = _current.get("diffuse", Color(1.0, 0.91, 0.78)) + var time_hours: float = float(_current.get("time_hours", _get_time_hours())) + var sun_elevation := _sun_elevation01(time_hours) + + _sky_material.sky_top_color = _sky_material.sky_top_color.lerp(sky_top, blend) + _sky_material.sky_horizon_color = _sky_material.sky_horizon_color.lerp(sky_horizon, blend) + _sky_material.ground_horizon_color = _sky_material.ground_horizon_color.lerp(sky_low, blend) + _sky_material.ground_bottom_color = _sky_material.ground_bottom_color.lerp(fog_color.darkened(0.55), blend) + _sky_material.sky_energy_multiplier = lerpf(_sky_material.sky_energy_multiplier, lerpf(0.28, 1.18, sun_elevation), blend) + _sky_material.ground_energy_multiplier = lerpf(_sky_material.ground_energy_multiplier, 0.45, blend) + _sky_material.sun_angle_max = 10.0 + _sky_material.sun_curve = 0.08 + + _environment.ambient_light_color = _environment.ambient_light_color.lerp(ambient, blend) + _environment.ambient_light_energy = lerpf(_environment.ambient_light_energy, clamp(_color_luma(ambient) * 1.45, 0.18, 1.05), blend) + _environment.ambient_light_sky_contribution = lerpf(_environment.ambient_light_sky_contribution, 0.65, blend) + _environment.background_energy_multiplier = lerpf(_environment.background_energy_multiplier, lerpf(0.35, 0.95, sun_elevation), blend) + _environment.fog_light_color = _environment.fog_light_color.lerp(fog_color, blend) + _environment.fog_light_energy = lerpf(_environment.fog_light_energy, 0.55, blend) + _environment.fog_sun_scatter = lerpf(_environment.fog_sun_scatter, 0.08, blend) + _environment.fog_sky_affect = lerpf(_environment.fog_sky_affect, 0.35, blend) + + var fog_end: float = maxf(800.0, float(_current.get("fog_end", 5200.0))) + var fog_start: float = clamp(float(_current.get("fog_start", fog_end * 0.25)), 0.0, fog_end - 50.0) + var dbc_fog_density: float = clamp(float(_current.get("fog_density", 0.5)), 0.0, 1.0) + _environment.fog_depth_begin = lerpf(_environment.fog_depth_begin, fog_start, blend) + _environment.fog_depth_end = lerpf(_environment.fog_depth_end, fog_end, blend) + _environment.fog_density = lerpf(_environment.fog_density, clamp(dbc_fog_density * 0.0002, 0.00002, 0.00022), blend) + + if _sun: + var sun_color := diffuse.lerp(Color(0.42, 0.46, 0.7), 1.0 - sun_elevation) + _sun.light_color = _sun.light_color.lerp(sun_color, blend) + _sun.light_energy = lerpf(_sun.light_energy, clamp(_color_luma(diffuse) * lerpf(0.22, 1.45, sun_elevation), 0.05, 1.6), blend) + _apply_sun_direction(time_hours) + + +func _load_lighting_dbcs() -> bool: + var base := _res_path(extracted_dir).path_join("DBFilesClient") + var light := _load_wdbc(base.path_join("Light.dbc")) + var light_params := _load_wdbc(base.path_join("LightParams.dbc")) + var light_skybox := _load_wdbc(base.path_join("LightSkybox.dbc")) + var int_band := _load_wdbc(base.path_join("LightIntBand.dbc")) + var float_band := _load_wdbc(base.path_join("LightFloatBand.dbc")) + var area_table := _load_wdbc(base.path_join("AreaTable.dbc")) + if light.is_empty() or int_band.is_empty() or float_band.is_empty(): + return false + _load_light_volumes(light) + if not light_params.is_empty(): + _load_light_params(light_params) + if not light_skybox.is_empty(): + _load_light_skyboxes(light_skybox) + if not area_table.is_empty(): + _load_area_table(area_table) + _load_int_bands(int_band) + _load_float_bands(float_band) + return not _profiles.is_empty() + + +func _load_light_volumes(dbc: Dictionary) -> void: + _light_volumes_by_map.clear() + _default_light_params_by_map.clear() + for i in int(dbc["records"]): + var record_map := _dbc_u32(dbc, i, 1) + var wx := _dbc_float(dbc, i, 2) / LIGHT_COORD_SCALE + var wz := _dbc_float(dbc, i, 3) / LIGHT_COORD_SCALE + var wy := _dbc_float(dbc, i, 4) / LIGHT_COORD_SCALE + var inner := maxf(0.0, _dbc_float(dbc, i, 5) / LIGHT_COORD_SCALE) + var outer := maxf(inner, _dbc_float(dbc, i, 6) / LIGHT_COORD_SCALE) + var normal := int(_dbc_u32(dbc, i, 7)) + var rain := int(_dbc_u32(dbc, i, 8)) + var underwater := int(_dbc_u32(dbc, i, 9)) + if normal <= 0: + continue + if outer <= 0.001: + if not _default_light_params_by_map.has(record_map): + _default_light_params_by_map[record_map] = normal + continue + if not _light_volumes_by_map.has(record_map): + _light_volumes_by_map[record_map] = [] + _light_volumes_by_map[record_map].append({ + "pos": Vector3(wx, wy, wz), + "inner": inner, + "outer": outer, + "normal": normal, + "rain": rain, + "underwater": underwater, + }) + + +func _load_int_bands(dbc: Dictionary) -> void: + for i in int(dbc["records"]): + var band_id := int(_dbc_u32(dbc, i, 0)) + if band_id <= 0: + continue + var param_id := int((band_id - 1) / 18) + 1 + var channel := (band_id - 1) % 18 + var profile := _get_or_create_profile(param_id) + var colors: Array = profile["colors"] + colors[channel] = _read_color_band(dbc, i) + + +func _load_float_bands(dbc: Dictionary) -> void: + for i in int(dbc["records"]): + var band_id := int(_dbc_u32(dbc, i, 0)) + if band_id <= 0: + continue + var param_id := int((band_id - 1) / 6) + 1 + var channel := (band_id - 1) % 6 + var profile := _get_or_create_profile(param_id) + var floats: Array = profile["floats"] + floats[channel] = _read_float_band(dbc, i) + + +func _load_light_params(dbc: Dictionary) -> void: + for i in int(dbc["records"]): + var param_id := int(_dbc_u32(dbc, i, 0)) + if param_id <= 0: + continue + var profile := _get_or_create_profile(param_id) + profile["highlight_sky"] = int(_dbc_u32(dbc, i, 1)) + profile["skybox_id"] = int(_dbc_u32(dbc, i, 2)) + + +func _load_light_skyboxes(dbc: Dictionary) -> void: + _light_skyboxes.clear() + for i in int(dbc["records"]): + var skybox_id := int(_dbc_u32(dbc, i, 0)) + if skybox_id <= 0: + continue + var path := _dbc_string(dbc, i, 1).replace("\\", "/") + if path.is_empty(): + continue + _light_skyboxes[skybox_id] = { + "path": path, + "flags": int(_dbc_u32(dbc, i, 2)), + } + + +func _load_area_table(dbc: Dictionary) -> void: + _area_table.clear() + for i in int(dbc["records"]): + var area_id := int(_dbc_u32(dbc, i, 0)) + if area_id <= 0: + continue + _area_table[area_id] = { + "map": int(_dbc_u32(dbc, i, 1)), + "parent": int(_dbc_u32(dbc, i, 2)), + "name": _dbc_string(dbc, i, 19), + } + + +func _get_or_create_profile(param_id: int) -> Dictionary: + if _profiles.has(param_id): + return _profiles[param_id] + var colors: Array = [] + var floats: Array = [] + colors.resize(18) + floats.resize(6) + var profile := {"colors": colors, "floats": floats} + _profiles[param_id] = profile + return profile + + +func _select_light_params(wow_pos: Vector3, area_id: int, zone_id: int) -> Array: + var volumes: Array = _light_volumes_by_map.get(map_id, []) + var weighted: Array = [] + for volume in volumes: + var pos: Vector3 = volume["pos"] + var d := Vector2(wow_pos.x - pos.x, wow_pos.y - pos.y).length() + var outer: float = volume["outer"] + if d > outer: + continue + var inner: float = volume["inner"] + var weight := 1.0 + if outer > inner: + weight = clamp((outer - d) / (outer - inner), 0.0, 1.0) + if weight > 0.0: + weighted.append({"id": int(volume["normal"]), "weight": weight, "source": "volume"}) + + weighted.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return float(a["weight"]) > float(b["weight"])) + if weighted.size() > 4: + weighted.resize(4) + + var total := 0.0 + for item in weighted: + total += float(item["weight"]) + if total > 0.0: + for item in weighted: + item["weight"] = float(item["weight"]) / total + return weighted + + var area_profile := _select_area_light_param(area_id, zone_id) + if area_profile > 0: + return [{"id": area_profile, "weight": 1.0, "source": "area", "area": area_id, "zone": zone_id}] + + var fallback := int(_default_light_params_by_map.get(map_id, _default_light_params_by_map.get(0, 12))) + return [{"id": fallback, "weight": 1.0, "source": "default"}] + + +func _select_area_light_param(area_id: int, zone_id: int) -> int: + if area_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(area_id): + return int(AREA_LIGHT_PARAMS_BY_ZONE[area_id]) + if zone_id > 0 and AREA_LIGHT_PARAMS_BY_ZONE.has(zone_id): + return int(AREA_LIGHT_PARAMS_BY_ZONE[zone_id]) + return 0 + + +func _select_skybox_id(selected: Array) -> int: + if debug_force_skybox_id > 0: + return debug_force_skybox_id if _light_skyboxes.has(debug_force_skybox_id) else 0 + for item in selected: + var id := int(item.get("id", 0)) + if not _profiles.has(id): + continue + var skybox_id := int((_profiles[id] as Dictionary).get("skybox_id", 0)) + if skybox_id > 0 and _light_skyboxes.has(skybox_id): + return skybox_id + return 0 + + +func _sample_blended_params(selected: Array, time_half: int) -> Dictionary: + var out := { + "ambient": Color(0.72, 0.80, 0.88), + "diffuse": Color(1.0, 0.91, 0.78), + "fog_color": Color(0.55, 0.66, 0.72), + "sky_top": Color(0.13, 0.32, 0.62), + "sky_middle": Color(0.36, 0.58, 0.86), + "sky_band_1": Color(0.62, 0.76, 0.90), + "sky_band_2": Color(0.50, 0.62, 0.72), + "fog_end": 5200.0, + "fog_start": 1200.0, + "fog_density": 0.6, + "cloud_density": 0.0, + } + var accum := {} + for key in out.keys(): + accum[key] = Color(0, 0, 0) if out[key] is Color else 0.0 + + var total := 0.0 + for item in selected: + var id := int(item["id"]) + var weight := float(item["weight"]) + if not _profiles.has(id) or weight <= 0.0: + continue + var sampled := _sample_profile(_profiles[id], time_half, out) + for key in sampled.keys(): + accum[key] = accum[key] + sampled[key] * weight + total += weight + + if total <= 0.0: + return out + for key in out.keys(): + out[key] = accum[key] if total == 1.0 else accum[key] / total + return out + + +func _sample_profile(profile: Dictionary, time_half: int, fallback: Dictionary) -> Dictionary: + var colors: Array = profile["colors"] + var floats: Array = profile["floats"] + var fog_end := _sample_float_band(floats[FOG_END], time_half, float(fallback["fog_end"])) + var fog_scalar := _sample_float_band(floats[FOG_START_SCALAR], time_half, 0.25) + return { + "ambient": _sample_color_band(colors[CH_AMBIENT], time_half, fallback["ambient"]), + "diffuse": _sample_color_band(colors[CH_DIFFUSE], time_half, fallback["diffuse"]), + "fog_color": _sample_color_band(colors[CH_FOG], time_half, fallback["fog_color"]), + "sky_top": _sample_color_band(colors[CH_SKY_TOP], time_half, fallback["sky_top"]), + "sky_middle": _sample_color_band(colors[CH_SKY_MIDDLE], time_half, fallback["sky_middle"]), + "sky_band_1": _sample_color_band(colors[CH_SKY_BAND_1], time_half, fallback["sky_band_1"]), + "sky_band_2": _sample_color_band(colors[CH_SKY_BAND_2], time_half, fallback["sky_band_2"]), + "fog_end": fog_end, + "fog_start": maxf(0.0, fog_end * fog_scalar), + "fog_density": _sample_float_band(floats[FOG_DENSITY], time_half, float(fallback["fog_density"])), + "cloud_density": _sample_float_band(floats[CLOUD_DENSITY], time_half, float(fallback["cloud_density"])), + } + + +func _read_color_band(dbc: Dictionary, record: int) -> Dictionary: + var count := mini(int(_dbc_u32(dbc, record, 1)), 16) + var times := PackedInt32Array() + var values: Array[Color] = [] + for i in count: + times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY) + values.append(_dbc_color(_dbc_u32(dbc, record, 18 + i))) + return {"times": times, "values": values} + + +func _read_float_band(dbc: Dictionary, record: int) -> Dictionary: + var count := mini(int(_dbc_u32(dbc, record, 1)), 16) + var times := PackedInt32Array() + var values := PackedFloat32Array() + for i in count: + times.append(int(_dbc_u32(dbc, record, 2 + i)) % HALF_MINUTES_PER_DAY) + values.append(_dbc_float(dbc, record, 18 + i)) + return {"times": times, "values": values} + + +func _sample_color_band(band_variant: Variant, time_half: int, fallback: Color) -> Color: + if not (band_variant is Dictionary): + return fallback + var band: Dictionary = band_variant + var times: PackedInt32Array = band.get("times", PackedInt32Array()) + var values: Array = band.get("values", []) + if times.is_empty() or values.is_empty(): + return fallback + if times.size() == 1: + return values[0] + var pair := _find_time_pair(times, time_half) + var t := pair.z + return (values[pair.x] as Color).lerp(values[pair.y] as Color, t) + + +func _sample_float_band(band_variant: Variant, time_half: int, fallback: float) -> float: + if not (band_variant is Dictionary): + return fallback + var band: Dictionary = band_variant + var times: PackedInt32Array = band.get("times", PackedInt32Array()) + var values: PackedFloat32Array = band.get("values", PackedFloat32Array()) + if times.is_empty() or values.is_empty(): + return fallback + if times.size() == 1: + return values[0] + var pair := _find_time_pair(times, time_half) + return lerpf(values[pair.x], values[pair.y], pair.z) + + +func _find_time_pair(times: PackedInt32Array, time_half: int) -> Vector3: + var idx1 := times.size() - 1 + var idx2 := 0 + for i in times.size(): + if time_half < times[i]: + idx2 = i + idx1 = i - 1 if i > 0 else times.size() - 1 + break + var t1 := times[idx1] + var t2 := times[idx2] + var span := t2 - t1 if t2 > t1 else HALF_MINUTES_PER_DAY - t1 + t2 + var elapsed := time_half - t1 if time_half >= t1 else HALF_MINUTES_PER_DAY - t1 + time_half + var alpha: float = clamp(float(elapsed) / maxf(1.0, float(span)), 0.0, 1.0) + return Vector3(idx1, idx2, alpha) + + +func _load_wdbc(path: String) -> Dictionary: + var abs_path := ProjectSettings.globalize_path(path) + if not FileAccess.file_exists(abs_path): + return {} + var file := FileAccess.open(abs_path, FileAccess.READ) + if not file: + return {} + var bytes := file.get_buffer(file.get_length()) + if bytes.size() < 20 or bytes[0] != 0x57 or bytes[1] != 0x44 or bytes[2] != 0x42 or bytes[3] != 0x43: + return {} + var records := int(bytes.decode_u32(4)) + var fields := int(bytes.decode_u32(8)) + var record_size := int(bytes.decode_u32(12)) + var string_size := int(bytes.decode_u32(16)) + var required := 20 + records * record_size + string_size + if records < 0 or fields <= 0 or record_size <= 0 or required > bytes.size(): + return {} + return { + "bytes": bytes, + "records": records, + "fields": fields, + "record_size": record_size, + "records_offset": 20, + "strings_offset": 20 + records * record_size, + "string_size": string_size, + } + + +func _dbc_u32(dbc: Dictionary, record: int, field: int) -> int: + if record < 0 or record >= int(dbc["records"]) or field < 0: + return 0 + var record_size := int(dbc["record_size"]) + var field_offset := field * 4 + if field_offset + 4 > record_size: + return 0 + var bytes: PackedByteArray = dbc["bytes"] + return int(bytes.decode_u32(int(dbc["records_offset"]) + record * record_size + field_offset)) + + +func _dbc_float(dbc: Dictionary, record: int, field: int) -> float: + if record < 0 or record >= int(dbc["records"]) or field < 0: + return 0.0 + var record_size := int(dbc["record_size"]) + var field_offset := field * 4 + if field_offset + 4 > record_size: + return 0.0 + var bytes: PackedByteArray = dbc["bytes"] + return bytes.decode_float(int(dbc["records_offset"]) + record * record_size + field_offset) + + +func _dbc_string(dbc: Dictionary, record: int, field: int) -> String: + var offset := _dbc_u32(dbc, record, field) + var string_size := int(dbc.get("string_size", 0)) + if offset <= 0 or offset >= string_size: + return "" + var bytes: PackedByteArray = dbc["bytes"] + var pos := int(dbc["strings_offset"]) + offset + var end := pos + var max_end := int(dbc["strings_offset"]) + string_size + while end < max_end and bytes[end] != 0: + end += 1 + if end <= pos: + return "" + return bytes.slice(pos, end).get_string_from_utf8() + + +func _dbc_color(value: int) -> Color: + var r := float(value & 0xFF) / 255.0 + var g := float((value >> 8) & 0xFF) / 255.0 + var b := float((value >> 16) & 0xFF) / 255.0 + return Color(r, g, b, 1.0) + + +func _get_time_hours() -> float: + if use_system_time: + var now := Time.get_datetime_dict_from_system() + return float(now.hour) + float(now.minute) / 60.0 + float(now.second) / 3600.0 + return fixed_time_hours + + +func _get_target_wow_position() -> Vector3: + var world_pos := Vector3.ZERO + if _target: + world_pos = _target.global_position + return Vector3( + WOW_WORLD_CENTER - world_pos.z, + WOW_WORLD_CENTER - world_pos.x, + world_pos.y) + + +func _get_target_area_id() -> int: + if not _target or map_name.is_empty(): + return 0 + var world_pos := _target.global_position + var tile_x := int(floor(world_pos.x / TILE_SIZE)) + var tile_y := int(floor(world_pos.z / TILE_SIZE)) + var local_x := world_pos.x - float(tile_x) * TILE_SIZE + var local_y := world_pos.z - float(tile_y) * TILE_SIZE + var chunk_x := clampi(int(floor(local_x / (TILE_SIZE / 16.0))), 0, 15) + var chunk_y := clampi(int(floor(local_y / (TILE_SIZE / 16.0))), 0, 15) + var areas := _load_adt_area_grid(tile_x, tile_y) + if areas.is_empty(): + return 0 + return int(areas[chunk_y * 16 + chunk_x]) + + +func _load_adt_area_grid(tile_x: int, tile_y: int) -> PackedInt32Array: + var key := "%d_%d" % [tile_x, tile_y] + if _adt_area_cache.has(key): + var cached: PackedInt32Array = _adt_area_cache[key] + return cached + + var areas := PackedInt32Array() + areas.resize(256) + var rel_path := _res_path(extracted_dir).path_join("World/Maps").path_join(map_name).path_join("%s_%d_%d.adt" % [map_name, tile_x, tile_y]) + var abs_path := ProjectSettings.globalize_path(rel_path) + if not FileAccess.file_exists(abs_path): + _adt_area_cache[key] = areas + return areas + + var file := FileAccess.open(abs_path, FileAccess.READ) + if not file: + _adt_area_cache[key] = areas + return areas + + var bytes := file.get_buffer(file.get_length()) + var offset := 0 + while offset + 8 <= bytes.size(): + var payload := offset + 8 + var size := int(bytes.decode_u32(offset + 4)) + if size < 0 or payload + size > bytes.size(): + offset += 1 + continue + + if _is_mcnk_magic(bytes, offset) and size >= 56: + var index_x := int(bytes.decode_u32(payload + 4)) + var index_y := int(bytes.decode_u32(payload + 8)) + var area_id := int(bytes.decode_u32(payload + 52)) + if index_x >= 0 and index_x < 16 and index_y >= 0 and index_y < 16: + areas[index_y * 16 + index_x] = area_id + + offset = payload + size + + _adt_area_cache[key] = areas + return areas + + +func _is_mcnk_magic(bytes: PackedByteArray, offset: int) -> bool: + if offset + 4 > bytes.size(): + return false + return ( + (bytes[offset] == 0x4B and bytes[offset + 1] == 0x4E and bytes[offset + 2] == 0x43 and bytes[offset + 3] == 0x4D) + or (bytes[offset] == 0x4D and bytes[offset + 1] == 0x43 and bytes[offset + 2] == 0x4E and bytes[offset + 3] == 0x4B) + ) + + +func _get_zone_area_id(area_id: int) -> int: + if area_id <= 0: + return 0 + var current := area_id + var visited := {} + for i in 32: + if not _area_table.has(current): + return current + var area: Dictionary = _area_table[current] + var parent := int(area.get("parent", 0)) + if parent <= 0: + return current + if visited.has(current): + return current + visited[current] = true + current = parent + return current + + +func _get_area_name(area_id: int) -> String: + if area_id <= 0 or not _area_table.has(area_id): + return "-" + var area: Dictionary = _area_table[area_id] + var name := str(area.get("name", "")) + return name if not name.is_empty() else "-" + + +func _update_skybox_model() -> void: + if not skybox_models_enabled or not _skybox_root: + return + var skybox_id := int(_current.get("skybox_id", 0)) + if skybox_id == _active_skybox_id: + return + _active_skybox_id = skybox_id + if _active_skybox_node: + _active_skybox_node.queue_free() + _active_skybox_node = null + if skybox_id <= 0: + return + var rel_path := _get_skybox_m2_path(skybox_id) + if rel_path.is_empty(): + return + var node := _load_skybox_m2(rel_path) + if not node: + return + node.name = "Skybox_%d" % skybox_id + node.scale = Vector3.ONE * maxf(0.001, skybox_model_scale) + _disable_skybox_shadows(node) + _skybox_root.add_child(node) + _active_skybox_node = node + print("SKYBOX_MODEL id=%d path=%s" % [skybox_id, rel_path]) + + +func _update_skybox_transform() -> void: + if not _skybox_root: + return + var pos := Vector3.ZERO + if _target: + pos = _target.global_position + pos.y += skybox_model_height_offset + _skybox_root.global_position = pos + + +func _get_skybox_m2_path(skybox_id: int) -> String: + if not _light_skyboxes.has(skybox_id): + return "" + var rel_path := str((_light_skyboxes[skybox_id] as Dictionary).get("path", "")).replace("\\", "/") + if rel_path.is_empty(): + return "" + if rel_path.get_extension().to_lower() == "mdx": + rel_path = rel_path.get_basename() + ".m2" + return rel_path + + +func _load_skybox_m2(rel_path: String) -> Node3D: + if _missing_skyboxes.has(rel_path): + return null + if not ClassDB.class_exists("M2Loader"): + return null + var abs_path := ProjectSettings.globalize_path(_res_path(extracted_dir).path_join(rel_path)) + if not FileAccess.file_exists(abs_path): + _missing_skyboxes[rel_path] = true + push_warning("WowSkyController: missing skybox model: %s" % rel_path) + return null + var loader = ClassDB.instantiate("M2Loader") + if loader == null: + return null + var data: Dictionary = loader.call("load_m2", abs_path) + if data.is_empty(): + _missing_skyboxes[rel_path] = true + return null + return M2_BUILDER_SCRIPT.build(data, extracted_dir) + + +func _disable_skybox_shadows(node: Node) -> void: + if node is GeometryInstance3D: + (node as GeometryInstance3D).cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + for child in node.get_children(): + _disable_skybox_shadows(child) + + +func _apply_sun_direction(time_hours: float) -> void: + var day_phase := fposmod(time_hours - 6.0, 24.0) / 24.0 + var azimuth := day_phase * TAU + var elevation := sin(clamp((time_hours - 6.0) / 12.0, 0.0, 1.0) * PI) + if time_hours < 6.0 or time_hours > 18.0: + elevation = -0.25 + var horizontal := sqrt(maxf(0.0, 1.0 - elevation * elevation)) + var sun_dir := Vector3(cos(azimuth) * horizontal, elevation, sin(azimuth) * horizontal).normalized() + var light_dir := -sun_dir + if abs(light_dir.dot(Vector3.UP)) > 0.98: + _sun.look_at(_sun.global_position + light_dir, Vector3.FORWARD) + else: + _sun.look_at(_sun.global_position + light_dir, Vector3.UP) + + +func _sun_elevation01(time_hours: float) -> float: + if time_hours < 6.0 or time_hours > 18.0: + return 0.0 + return clamp(sin(((time_hours - 6.0) / 12.0) * PI), 0.0, 1.0) + + +func _color_luma(color: Color) -> float: + return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722 + + +func _get_light_volume_count(id: int) -> int: + return int((_light_volumes_by_map.get(id, []) as Array).size()) + + +func _res_path(path: String) -> String: + return path.trim_suffix("/") diff --git a/src/scenes/sky/wow_sky_controller.gd.uid b/src/scenes/sky/wow_sky_controller.gd.uid new file mode 100644 index 0000000..a4cf6f1 --- /dev/null +++ b/src/scenes/sky/wow_sky_controller.gd.uid @@ -0,0 +1 @@ +uid://didjth34ut0v1 diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index 7b75bb7..78f9a6e 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -1,7 +1,21 @@ [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" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"] +[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"] +[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"] + +[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] +radius = 0.45 +height = 2.1 + +[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="ProceduralSkyMaterial" id="ProceduralSkyMaterial_1"] sky_top_color = Color(0.13, 0.32, 0.62, 1) @@ -14,20 +28,18 @@ ground_curve = 0.12 ground_energy_multiplier = 0.55 sun_angle_max = 12.0 sun_curve = 0.08 -use_debanding = true [sub_resource type="Sky" id="Sky_1"] sky_material = SubResource("ProceduralSkyMaterial_1") -radiance_size = 3 [sub_resource type="Environment" id="Environment_1"] background_mode = 2 -sky = SubResource("Sky_1") background_energy_multiplier = 0.9 +sky = SubResource("Sky_1") ambient_light_source = 3 ambient_light_color = Color(0.72, 0.8, 0.88, 1) -ambient_light_energy = 0.75 ambient_light_sky_contribution = 0.65 +ambient_light_energy = 0.75 reflected_light_source = 2 fog_enabled = true fog_mode = 1 @@ -36,23 +48,9 @@ fog_light_energy = 0.55 fog_sun_scatter = 0.08 fog_density = 0.00018 fog_sky_affect = 0.35 -fog_depth_curve = 1.0 fog_depth_begin = 1200.0 fog_depth_end = 5200.0 -[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("ThirdPersonPlayer/CameraPivot/Camera3D") @@ -65,7 +63,7 @@ m2_build_groups_per_tick = 1 m2_multimesh_batch_size = 64 wmo_render_group_ops_per_tick = 16 cached_tile_mesh_limit = 48 -terrain_quality_mesh_cache_limit = 128 +terrain_quality_mesh_cache_limit = 48 lod0_radius_chunks = 10 lod1_radius_chunks = 20 lod2_radius_chunks = 40 @@ -77,9 +75,7 @@ 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 @@ -89,33 +85,30 @@ 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 [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 +spawn_tile_x = 31 +spawn_tile_y = 31 -[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer"] -position = Vector3(0, 1.05, 0) +[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer" unique_id=1297880621] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0) shape = SubResource("PlayerCapsuleShape_1") -[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer"] -position = Vector3(0, 1.05, 0) +[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0) mesh = SubResource("PlayerCapsuleMesh_1") -[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer"] -position = Vector3(0, 1.7, 0) +[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0) -[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot"] -position = Vector3(0, 0, 8) +[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) current = true -far = 50000.0 fov = 70.0 +far = 50000.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) @@ -127,3 +120,9 @@ directional_shadow_max_distance = 2200.0 [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=12906896] environment = SubResource("Environment_1") + +[node name="WowSkyController" type="Node" parent="." unique_id=1473026848] +script = ExtResource("3_sky") +target_path = NodePath("../ThirdPersonPlayer") +world_environment_path = NodePath("../WorldEnvironment") +sun_path = NodePath("../Sun")