скайбоксы, рендер воды, документация
This commit is contained in:
@@ -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/<Map>/<Map>_<x>_<y>.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.
|
||||||
Binary file not shown.
@@ -2,6 +2,8 @@
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
class_name ADTBuilder
|
class_name ADTBuilder
|
||||||
|
|
||||||
|
const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd")
|
||||||
|
|
||||||
const TILE_SIZE := 533.33333
|
const TILE_SIZE := 533.33333
|
||||||
const CHUNK_SIZE := 33.33333
|
const CHUNK_SIZE := 33.33333
|
||||||
const UNIT_SIZE := CHUNK_SIZE / 8.0
|
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 _baked_texture_material_cache: Dictionary = {}
|
||||||
static var _alpha_texture_cache: Dictionary = {}
|
static var _alpha_texture_cache: Dictionary = {}
|
||||||
static var _layered_material_cache: Dictionary = {}
|
static var _layered_material_cache: Dictionary = {}
|
||||||
static var _liquid_material_cache: Dictionary = {}
|
|
||||||
|
|
||||||
## Build a Node3D with 256 terrain chunk meshes.
|
## Build a Node3D with 256 terrain chunk meshes.
|
||||||
## `extracted_dir` must be an absolute path to the extracted/ folder so BLPs can be loaded.
|
## `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()
|
var mi := MeshInstance3D.new()
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
mi.name = "Liquid_%d" % int(liquid_id)
|
mi.name = "Liquid_%d" % int(liquid_id)
|
||||||
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
root.add_child(mi)
|
root.add_child(mi)
|
||||||
|
|
||||||
return root if root.get_child_count() > 0 else null
|
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 y10 := liquid_heights[z * 9 + x + 1] - origin_offset.y
|
||||||
var y01 := liquid_heights[(z + 1) * 9 + x] - 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 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, y00, z0))
|
||||||
verts.append(Vector3(x0, y01, z1))
|
verts.append(Vector3(x0, y01, z1))
|
||||||
@@ -828,10 +834,10 @@ static func _append_liquid_geometry(
|
|||||||
nrms.append(Vector3.UP)
|
nrms.append(Vector3.UP)
|
||||||
nrms.append(Vector3.UP)
|
nrms.append(Vector3.UP)
|
||||||
|
|
||||||
uvs_arr.append(Vector2(float(x) / 8.0, float(z) / 8.0))
|
uvs_arr.append(Vector2(u0, v0))
|
||||||
uvs_arr.append(Vector2(float(x) / 8.0, float(z + 1) / 8.0))
|
uvs_arr.append(Vector2(u0, v1))
|
||||||
uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z + 1) / 8.0))
|
uvs_arr.append(Vector2(u1, v1))
|
||||||
uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z) / 8.0))
|
uvs_arr.append(Vector2(u1, v0))
|
||||||
|
|
||||||
indices.append(base)
|
indices.append(base)
|
||||||
indices.append(base + 1)
|
indices.append(base + 1)
|
||||||
@@ -912,6 +918,7 @@ static func _build_liquid_mesh(
|
|||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
mi.mesh = mesh
|
mi.mesh = mesh
|
||||||
mi.position = chunk_origin - origin_offset
|
mi.position = chunk_origin - origin_offset
|
||||||
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
||||||
mi.name = "Liquid_%d_%d_%d" % [
|
mi.name = "Liquid_%d_%d_%d" % [
|
||||||
chunk.get("index_x", 0),
|
chunk.get("index_x", 0),
|
||||||
chunk.get("index_y", 0),
|
chunk.get("index_y", 0),
|
||||||
@@ -938,33 +945,8 @@ static func _build_chunk_material(
|
|||||||
return _build_fallback_material()
|
return _build_fallback_material()
|
||||||
|
|
||||||
|
|
||||||
static func _build_liquid_material(liquid_id: int) -> StandardMaterial3D:
|
static func _build_liquid_material(liquid_id: int) -> Material:
|
||||||
if _liquid_material_cache.has(liquid_id):
|
return WOW_LIQUID_MATERIAL.build(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_terrain_material(
|
static func _build_terrain_material(
|
||||||
@@ -1287,18 +1269,20 @@ uniform float ambient = 0.62;
|
|||||||
uniform float diffuse = 0.38;
|
uniform float diffuse = 0.38;
|
||||||
uniform float hemi = 0.12;
|
uniform float hemi = 0.12;
|
||||||
|
|
||||||
vec3 sample_layer(float layer, vec2 local_uv) {
|
vec3 sample_layer(float layer, vec2 continuous_chunk_uv) {
|
||||||
return texture(terrain_tex, vec3(local_uv * uv_scale, layer), mip_bias).rgb;
|
return texture(terrain_tex, vec3(continuous_chunk_uv * uv_scale, layer), mip_bias).rgb;
|
||||||
}
|
}
|
||||||
|
|
||||||
void fragment() {
|
void fragment() {
|
||||||
vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999));
|
vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999));
|
||||||
vec2 chunk_f = floor(tile_uv * 16.0);
|
vec2 chunk_uv = tile_uv * 16.0;
|
||||||
vec2 local_uv = fract(tile_uv * 16.0);
|
vec2 chunk_f = floor(chunk_uv);
|
||||||
|
vec2 local_uv = chunk_uv - chunk_f;
|
||||||
|
|
||||||
vec4 packed_ids = COLOR;
|
vec4 packed_ids = COLOR;
|
||||||
vec4 layers = floor(packed_ids * 255.0 + vec4(0.5));
|
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 w1 = alpha.r;
|
||||||
float w2 = alpha.g;
|
float w2 = alpha.g;
|
||||||
@@ -1311,10 +1295,10 @@ void fragment() {
|
|||||||
w3 /= sum;
|
w3 /= sum;
|
||||||
|
|
||||||
vec3 albedo =
|
vec3 albedo =
|
||||||
sample_layer(layers.x, local_uv) * w0 +
|
sample_layer(layers.x, chunk_uv) * w0 +
|
||||||
sample_layer(layers.y, local_uv) * w1 +
|
sample_layer(layers.y, chunk_uv) * w1 +
|
||||||
sample_layer(layers.z, local_uv) * w2 +
|
sample_layer(layers.z, chunk_uv) * w2 +
|
||||||
sample_layer(layers.w, local_uv) * w3;
|
sample_layer(layers.w, chunk_uv) * w3;
|
||||||
|
|
||||||
vec3 n = normalize(NORMAL);
|
vec3 n = normalize(NORMAL);
|
||||||
if (!FRONT_FACING) {
|
if (!FRONT_FACING) {
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
var textures: PackedStringArray = data.get("textures", PackedStringArray())
|
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 materials: Array = data.get("materials", [])
|
||||||
var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array())
|
var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array())
|
||||||
|
var model_path: String = str(data.get("model_path", ""))
|
||||||
|
|
||||||
var mesh := ArrayMesh.new()
|
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 surf_idx := mesh.get_surface_count() - 1
|
||||||
var mat_def: Dictionary = materials[mat_id] if mat_id >= 0 and mat_id < materials.size() else {}
|
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:
|
if mesh.get_surface_count() == 0:
|
||||||
return root
|
return root
|
||||||
@@ -71,36 +84,91 @@ static func _build_material(
|
|||||||
mat_def: Dictionary,
|
mat_def: Dictionary,
|
||||||
texture_combo_index: int,
|
texture_combo_index: int,
|
||||||
textures: PackedStringArray,
|
textures: PackedStringArray,
|
||||||
|
texture_types: PackedInt32Array,
|
||||||
|
texture_flags: PackedInt32Array,
|
||||||
tex_combos: PackedInt32Array,
|
tex_combos: PackedInt32Array,
|
||||||
extracted_dir: String) -> StandardMaterial3D:
|
extracted_dir: String,
|
||||||
|
model_path: String) -> StandardMaterial3D:
|
||||||
|
|
||||||
var mat := StandardMaterial3D.new()
|
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.roughness = 0.85
|
||||||
mat.metallic = 0.0
|
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.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
|
||||||
mat.vertex_color_use_as_albedo = false
|
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)
|
var blend_mode: int = mat_def.get("blend_mode", 0)
|
||||||
match blend_mode:
|
match blend_mode:
|
||||||
0: mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
|
0:
|
||||||
1: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
|
||||||
_: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
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():
|
if texture_combo_index >= 0 and texture_combo_index < tex_combos.size():
|
||||||
var tex_idx: int = tex_combos[texture_combo_index]
|
var tex_idx: int = tex_combos[texture_combo_index]
|
||||||
if tex_idx >= 0 and tex_idx < textures.size():
|
if tex_idx >= 0 and tex_idx < textures.size():
|
||||||
var tex_path: String = str(textures[tex_idx]).replace("\\", "/")
|
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():
|
if not tex_path.is_empty():
|
||||||
var tex := _load_texture(tex_path, extracted_dir)
|
var tex := _load_texture(tex_path, extracted_dir)
|
||||||
if tex:
|
if tex:
|
||||||
mat.albedo_texture = tex
|
mat.albedo_texture = tex
|
||||||
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
|
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
|
||||||
|
mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, (tex_flags & 0x03) != 0)
|
||||||
|
|
||||||
return mat
|
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:
|
static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D:
|
||||||
if rel_path.is_empty() or extracted_dir.is_empty():
|
if rel_path.is_empty() or extracted_dir.is_empty():
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
class_name WMOBuilder
|
class_name WMOBuilder
|
||||||
|
|
||||||
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
|
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 M2_RIGHT_YAW_OFFSET := PI * 0.5
|
||||||
const BUILD_OCCLUDERS := false
|
const BUILD_OCCLUDERS := false
|
||||||
const OCCLUDER_MIN_TRIANGLES := 16
|
const OCCLUDER_MIN_TRIANGLES := 16
|
||||||
@@ -27,7 +28,7 @@ static func clear_caches() -> void:
|
|||||||
_m2_prototype_cache.clear()
|
_m2_prototype_cache.clear()
|
||||||
_m2_missing_cache.clear()
|
_m2_missing_cache.clear()
|
||||||
_texture_cache.clear()
|
_texture_cache.clear()
|
||||||
_wmo_liquid_material_cache.clear()
|
WOW_LIQUID_MATERIAL.clear_cache()
|
||||||
|
|
||||||
# Returns a Node3D containing one MeshInstance3D per WMO group.
|
# Returns a Node3D containing one MeshInstance3D per WMO group.
|
||||||
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
|
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.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
|
mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
|
||||||
mat.vertex_color_use_as_albedo = false
|
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)
|
var blend_mode: int = mat_def.get("blend_mode", 0)
|
||||||
match blend_mode:
|
match blend_mode:
|
||||||
@@ -356,6 +358,7 @@ static func _build_material(
|
|||||||
if tex:
|
if tex:
|
||||||
mat.albedo_texture = tex
|
mat.albedo_texture = tex
|
||||||
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
|
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_flags", mat_def.get("flags", 0))
|
||||||
mat.set_meta("wow_shader", mat_def.get("shader", 0))
|
mat.set_meta("wow_shader", mat_def.get("shader", 0))
|
||||||
@@ -363,7 +366,6 @@ static func _build_material(
|
|||||||
return mat
|
return mat
|
||||||
|
|
||||||
|
|
||||||
static var _wmo_liquid_material_cache: Dictionary = {}
|
|
||||||
|
|
||||||
# WMO MLIQ → MeshInstance3D in WMO-local coordinates.
|
# WMO MLIQ → MeshInstance3D in WMO-local coordinates.
|
||||||
# Corner & vertex grid are in WMO-local (Z-up); convert to Godot Y-up using the
|
# 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
|
return null
|
||||||
|
|
||||||
var verts := PackedVector3Array()
|
var verts := PackedVector3Array()
|
||||||
|
var nrms := PackedVector3Array()
|
||||||
|
var uvs := PackedVector2Array()
|
||||||
var indices := PackedInt32Array()
|
var indices := PackedInt32Array()
|
||||||
|
|
||||||
# Pre-compute Godot-space position for each grid vertex.
|
# 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[i10])
|
||||||
verts.append(pos[i11])
|
verts.append(pos[i11])
|
||||||
verts.append(pos[i01])
|
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.
|
# Two triangles, winding chosen to face up after the (-y, z, -x) flip.
|
||||||
indices.append(base + 0)
|
indices.append(base + 0)
|
||||||
indices.append(base + 1)
|
indices.append(base + 1)
|
||||||
@@ -431,6 +443,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D:
|
|||||||
var arrays := []
|
var arrays := []
|
||||||
arrays.resize(Mesh.ARRAY_MAX)
|
arrays.resize(Mesh.ARRAY_MAX)
|
||||||
arrays[Mesh.ARRAY_VERTEX] = verts
|
arrays[Mesh.ARRAY_VERTEX] = verts
|
||||||
|
arrays[Mesh.ARRAY_NORMAL] = nrms
|
||||||
|
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
||||||
arrays[Mesh.ARRAY_INDEX] = indices
|
arrays[Mesh.ARRAY_INDEX] = indices
|
||||||
|
|
||||||
var mesh := ArrayMesh.new()
|
var mesh := ArrayMesh.new()
|
||||||
@@ -443,19 +457,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D:
|
|||||||
return mi
|
return mi
|
||||||
|
|
||||||
|
|
||||||
static func _wmo_liquid_material(material_id: int) -> StandardMaterial3D:
|
static func _wmo_liquid_material(material_id: int) -> Material:
|
||||||
if _wmo_liquid_material_cache.has(material_id):
|
return WOW_LIQUID_MATERIAL.build_wmo(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 _load_texture(rel_path: String, extracted_dir: String) -> Texture2D:
|
static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d10ykrmo5h0ih
|
||||||
@@ -180,6 +180,7 @@ Ref<Image> BLPLoader::parse(const uint8_t *data, size_t len) {
|
|||||||
const size_t palette_ofs = sizeof(BLP2Header);
|
const size_t palette_ofs = sizeof(BLP2Header);
|
||||||
if (palette_ofs + 256*4 > len) return Ref<Image>();
|
if (palette_ofs + 256*4 > len) return Ref<Image>();
|
||||||
const uint8_t *palette = data + palette_ofs;
|
const uint8_t *palette = data + palette_ofs;
|
||||||
|
const uint8_t *alpha = mip + (size_t)w * (size_t)h;
|
||||||
|
|
||||||
rgba.resize(w * h * 4);
|
rgba.resize(w * h * 4);
|
||||||
for (uint32_t i = 0; i < w * h && i < mip_size; ++i) {
|
for (uint32_t i = 0; i < w * h && i < mip_size; ++i) {
|
||||||
@@ -187,7 +188,23 @@ Ref<Image> BLPLoader::parse(const uint8_t *data, size_t len) {
|
|||||||
rgba[i*4+0] = palette[idx*4+2]; // R (from BGR)
|
rgba[i*4+0] = palette[idx*4+2]; // R (from BGR)
|
||||||
rgba[i*4+1] = palette[idx*4+1]; // G
|
rgba[i*4+1] = palette[idx*4+1]; // G
|
||||||
rgba[i*4+2] = palette[idx*4+0]; // B
|
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) {
|
} else if (hdr.encoding == 3) {
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
|
|||||||
|
|
||||||
// ── Textures ─────────────────────────────────────────────────────────────
|
// ── Textures ─────────────────────────────────────────────────────────────
|
||||||
PackedStringArray textures;
|
PackedStringArray textures;
|
||||||
|
PackedInt32Array texture_types;
|
||||||
|
PackedInt32Array texture_flags;
|
||||||
const auto *tex_arr = safe_array<M2Texture>(buf, hdr.ofsTextures, hdr.nTextures);
|
const auto *tex_arr = safe_array<M2Texture>(buf, hdr.ofsTextures, hdr.nTextures);
|
||||||
if (tex_arr) {
|
if (tex_arr) {
|
||||||
for (uint32_t i = 0; i < hdr.nTextures; ++i) {
|
for (uint32_t i = 0; i < hdr.nTextures; ++i) {
|
||||||
@@ -217,6 +219,8 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
textures.push_back(to_godot(fname));
|
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<uint8_t> &buf, const std::string
|
|||||||
|
|
||||||
Dictionary result;
|
Dictionary result;
|
||||||
result["textures"] = textures;
|
result["textures"] = textures;
|
||||||
|
result["texture_types"] = texture_types;
|
||||||
|
result["texture_flags"] = texture_flags;
|
||||||
result["materials"] = materials;
|
result["materials"] = materials;
|
||||||
result["texture_combos"] = texture_combos;
|
result["texture_combos"] = texture_combos;
|
||||||
|
result["model_path"] = to_godot(path);
|
||||||
result["vertices"] = vertices;
|
result["vertices"] = vertices;
|
||||||
result["normals"] = normals;
|
result["normals"] = normals;
|
||||||
result["uvs"] = uvs;
|
result["uvs"] = uvs;
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ namespace godot {
|
|||||||
// Return Dictionary:
|
// Return Dictionary:
|
||||||
// {
|
// {
|
||||||
// "textures": PackedStringArray, # .blp texture paths (may be empty for replaceable)
|
// "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}]
|
// "materials": Array[Dictionary], # [{flags, blend_mode}]
|
||||||
// "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures
|
// "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures
|
||||||
|
// "model_path": String, # absolute source .m2 path
|
||||||
// "vertices": PackedVector3Array, # Godot-space positions
|
// "vertices": PackedVector3Array, # Godot-space positions
|
||||||
// "normals": PackedVector3Array,
|
// "normals": PackedVector3Array,
|
||||||
// "uvs": PackedVector2Array,
|
// "uvs": PackedVector2Array,
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ Dictionary WMOLoader::parse_group(const std::vector<uint8_t> &buf) {
|
|||||||
auto *v = sc.array<MOTVEntry>();
|
auto *v = sc.array<MOTVEntry>();
|
||||||
uvs.resize(n);
|
uvs.resize(n);
|
||||||
for (uint32_t i = 0; i < n; ++i)
|
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")) {
|
} else if (sc.is("MOVI")) {
|
||||||
uint32_t n = sc.size / 2;
|
uint32_t n = sc.size / 2;
|
||||||
|
|||||||
@@ -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("/")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://didjth34ut0v1
|
||||||
@@ -1,7 +1,21 @@
|
|||||||
[gd_scene format=3 uid="uid://f1nqi4emji47"]
|
[gd_scene format=3 uid="uid://f1nqi4emji47"]
|
||||||
|
|
||||||
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"]
|
[ext_resource type="Script" uid="uid://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"]
|
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_1"]
|
||||||
sky_top_color = Color(0.13, 0.32, 0.62, 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
|
ground_energy_multiplier = 0.55
|
||||||
sun_angle_max = 12.0
|
sun_angle_max = 12.0
|
||||||
sun_curve = 0.08
|
sun_curve = 0.08
|
||||||
use_debanding = true
|
|
||||||
|
|
||||||
[sub_resource type="Sky" id="Sky_1"]
|
[sub_resource type="Sky" id="Sky_1"]
|
||||||
sky_material = SubResource("ProceduralSkyMaterial_1")
|
sky_material = SubResource("ProceduralSkyMaterial_1")
|
||||||
radiance_size = 3
|
|
||||||
|
|
||||||
[sub_resource type="Environment" id="Environment_1"]
|
[sub_resource type="Environment" id="Environment_1"]
|
||||||
background_mode = 2
|
background_mode = 2
|
||||||
sky = SubResource("Sky_1")
|
|
||||||
background_energy_multiplier = 0.9
|
background_energy_multiplier = 0.9
|
||||||
|
sky = SubResource("Sky_1")
|
||||||
ambient_light_source = 3
|
ambient_light_source = 3
|
||||||
ambient_light_color = Color(0.72, 0.8, 0.88, 1)
|
ambient_light_color = Color(0.72, 0.8, 0.88, 1)
|
||||||
ambient_light_energy = 0.75
|
|
||||||
ambient_light_sky_contribution = 0.65
|
ambient_light_sky_contribution = 0.65
|
||||||
|
ambient_light_energy = 0.75
|
||||||
reflected_light_source = 2
|
reflected_light_source = 2
|
||||||
fog_enabled = true
|
fog_enabled = true
|
||||||
fog_mode = 1
|
fog_mode = 1
|
||||||
@@ -36,23 +48,9 @@ fog_light_energy = 0.55
|
|||||||
fog_sun_scatter = 0.08
|
fog_sun_scatter = 0.08
|
||||||
fog_density = 0.00018
|
fog_density = 0.00018
|
||||||
fog_sky_affect = 0.35
|
fog_sky_affect = 0.35
|
||||||
fog_depth_curve = 1.0
|
|
||||||
fog_depth_begin = 1200.0
|
fog_depth_begin = 1200.0
|
||||||
fog_depth_end = 5200.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]
|
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
|
||||||
script = ExtResource("1_stream")
|
script = ExtResource("1_stream")
|
||||||
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
|
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
|
||||||
@@ -65,7 +63,7 @@ m2_build_groups_per_tick = 1
|
|||||||
m2_multimesh_batch_size = 64
|
m2_multimesh_batch_size = 64
|
||||||
wmo_render_group_ops_per_tick = 16
|
wmo_render_group_ops_per_tick = 16
|
||||||
cached_tile_mesh_limit = 48
|
cached_tile_mesh_limit = 48
|
||||||
terrain_quality_mesh_cache_limit = 128
|
terrain_quality_mesh_cache_limit = 48
|
||||||
lod0_radius_chunks = 10
|
lod0_radius_chunks = 10
|
||||||
lod1_radius_chunks = 20
|
lod1_radius_chunks = 20
|
||||||
lod2_radius_chunks = 40
|
lod2_radius_chunks = 40
|
||||||
@@ -77,9 +75,7 @@ boundary_prefetch_threshold = 0.42
|
|||||||
auto_position_camera = false
|
auto_position_camera = false
|
||||||
enable_occlusion_culling = false
|
enable_occlusion_culling = false
|
||||||
terrain_full_quality_radius_tiles = 5
|
terrain_full_quality_radius_tiles = 5
|
||||||
terrain_control_splat_primary = true
|
|
||||||
terrain_control_splat_hide_fallback = true
|
terrain_control_splat_hide_fallback = true
|
||||||
terrain_control_splat_hide_fallback_radius_tiles = 0
|
|
||||||
terrain_control_splat_radius_tiles = 5
|
terrain_control_splat_radius_tiles = 5
|
||||||
max_active_terrain_control_splat_tiles = 121
|
max_active_terrain_control_splat_tiles = 121
|
||||||
terrain_control_splat_cache_finalize_ops_per_tick = 8
|
terrain_control_splat_cache_finalize_ops_per_tick = 8
|
||||||
@@ -89,33 +85,30 @@ m2_tile_radius = 3
|
|||||||
wmo_tile_radius = 5
|
wmo_tile_radius = 5
|
||||||
m2_visibility_range = 1600.0
|
m2_visibility_range = 1600.0
|
||||||
wmo_visibility_range = 3600.0
|
wmo_visibility_range = 3600.0
|
||||||
terrain_quality_log_enabled = true
|
|
||||||
terrain_quality_log_interval = 1.0
|
|
||||||
hitch_profiler_enabled = true
|
hitch_profiler_enabled = true
|
||||||
|
|
||||||
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
|
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 22666, 80, 15200)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 22666, 80, 15200)
|
||||||
script = ExtResource("2_player")
|
script = ExtResource("2_player")
|
||||||
spawn_tile_x = 42
|
spawn_tile_x = 31
|
||||||
spawn_tile_y = 28
|
spawn_tile_y = 31
|
||||||
sprint_multiplier = 6.0
|
|
||||||
|
|
||||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer"]
|
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer" unique_id=1297880621]
|
||||||
position = Vector3(0, 1.05, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
|
||||||
shape = SubResource("PlayerCapsuleShape_1")
|
shape = SubResource("PlayerCapsuleShape_1")
|
||||||
|
|
||||||
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer"]
|
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
|
||||||
position = Vector3(0, 1.05, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
|
||||||
mesh = SubResource("PlayerCapsuleMesh_1")
|
mesh = SubResource("PlayerCapsuleMesh_1")
|
||||||
|
|
||||||
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer"]
|
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
|
||||||
position = Vector3(0, 1.7, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot"]
|
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971]
|
||||||
position = Vector3(0, 0, 8)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
|
||||||
current = true
|
current = true
|
||||||
far = 50000.0
|
|
||||||
fov = 70.0
|
fov = 70.0
|
||||||
|
far = 50000.0
|
||||||
|
|
||||||
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=1436804627]
|
[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)
|
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]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=12906896]
|
||||||
environment = SubResource("Environment_1")
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user