скайбоксы, рендер воды, документация

This commit is contained in:
2026-06-27 18:33:55 +04:00
parent 579b9e10c9
commit b051012920
14 changed files with 1775 additions and 101 deletions
+546
View File
@@ -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.
+25 -41
View File
@@ -2,6 +2,8 @@
extends RefCounted
class_name ADTBuilder
const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd")
const TILE_SIZE := 533.33333
const CHUNK_SIZE := 33.33333
const UNIT_SIZE := CHUNK_SIZE / 8.0
@@ -18,7 +20,6 @@ static var _single_texture_material_cache: Dictionary = {}
static var _baked_texture_material_cache: Dictionary = {}
static var _alpha_texture_cache: Dictionary = {}
static var _layered_material_cache: Dictionary = {}
static var _liquid_material_cache: Dictionary = {}
## Build a Node3D with 256 terrain chunk meshes.
## `extracted_dir` must be an absolute path to the extracted/ folder so BLPs can be loaded.
@@ -780,6 +781,7 @@ static func _build_tile_water_root(data: Dictionary, origin_offset: Vector3) ->
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.name = "Liquid_%d" % int(liquid_id)
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
root.add_child(mi)
return root if root.get_child_count() > 0 else null
@@ -817,6 +819,10 @@ static func _append_liquid_geometry(
var y10 := liquid_heights[z * 9 + x + 1] - origin_offset.y
var y01 := liquid_heights[(z + 1) * 9 + x] - origin_offset.y
var y11 := liquid_heights[(z + 1) * 9 + x + 1] - origin_offset.y
var u0 := x0 / TILE_SIZE
var u1 := x1 / TILE_SIZE
var v0 := z0 / TILE_SIZE
var v1 := z1 / TILE_SIZE
verts.append(Vector3(x0, y00, z0))
verts.append(Vector3(x0, y01, z1))
@@ -828,10 +834,10 @@ static func _append_liquid_geometry(
nrms.append(Vector3.UP)
nrms.append(Vector3.UP)
uvs_arr.append(Vector2(float(x) / 8.0, float(z) / 8.0))
uvs_arr.append(Vector2(float(x) / 8.0, float(z + 1) / 8.0))
uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z + 1) / 8.0))
uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z) / 8.0))
uvs_arr.append(Vector2(u0, v0))
uvs_arr.append(Vector2(u0, v1))
uvs_arr.append(Vector2(u1, v1))
uvs_arr.append(Vector2(u1, v0))
indices.append(base)
indices.append(base + 1)
@@ -912,6 +918,7 @@ static func _build_liquid_mesh(
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.position = chunk_origin - origin_offset
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mi.name = "Liquid_%d_%d_%d" % [
chunk.get("index_x", 0),
chunk.get("index_y", 0),
@@ -938,33 +945,8 @@ static func _build_chunk_material(
return _build_fallback_material()
static func _build_liquid_material(liquid_id: int) -> StandardMaterial3D:
if _liquid_material_cache.has(liquid_id):
return _liquid_material_cache[liquid_id]
var color := _get_liquid_color(liquid_id)
var mat := StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = color
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.roughness = 0.08
mat.metallic = 0.0
mat.emission_enabled = true
mat.emission = Color(color.r, color.g, color.b) * 0.08
_liquid_material_cache[liquid_id] = mat
return mat
static func _get_liquid_color(liquid_id: int) -> Color:
match liquid_id:
2, 14:
return Color(0.08, 0.20, 0.34, 0.62)
3, 15, 19:
return Color(0.75, 0.28, 0.03, 0.72)
4, 20:
return Color(0.24, 0.45, 0.14, 0.68)
_:
return Color(0.12, 0.33, 0.50, 0.58)
static func _build_liquid_material(liquid_id: int) -> Material:
return WOW_LIQUID_MATERIAL.build(liquid_id)
static func _build_terrain_material(
@@ -1287,18 +1269,20 @@ uniform float ambient = 0.62;
uniform float diffuse = 0.38;
uniform float hemi = 0.12;
vec3 sample_layer(float layer, vec2 local_uv) {
return texture(terrain_tex, vec3(local_uv * uv_scale, layer), mip_bias).rgb;
vec3 sample_layer(float layer, vec2 continuous_chunk_uv) {
return texture(terrain_tex, vec3(continuous_chunk_uv * uv_scale, layer), mip_bias).rgb;
}
void fragment() {
vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999));
vec2 chunk_f = floor(tile_uv * 16.0);
vec2 local_uv = fract(tile_uv * 16.0);
vec2 chunk_uv = tile_uv * 16.0;
vec2 chunk_f = floor(chunk_uv);
vec2 local_uv = chunk_uv - chunk_f;
vec4 packed_ids = COLOR;
vec4 layers = floor(packed_ids * 255.0 + vec4(0.5));
vec3 alpha = texture(alpha_atlas, (chunk_f * 64.0 + local_uv * 64.0 + vec2(0.5)) / 1024.0).rgb;
vec2 alpha_px = clamp(local_uv * 64.0, vec2(0.5), vec2(63.5));
vec3 alpha = texture(alpha_atlas, (chunk_f * 64.0 + alpha_px) / 1024.0).rgb;
float w1 = alpha.r;
float w2 = alpha.g;
@@ -1311,10 +1295,10 @@ void fragment() {
w3 /= sum;
vec3 albedo =
sample_layer(layers.x, local_uv) * w0 +
sample_layer(layers.y, local_uv) * w1 +
sample_layer(layers.z, local_uv) * w2 +
sample_layer(layers.w, local_uv) * w3;
sample_layer(layers.x, chunk_uv) * w0 +
sample_layer(layers.y, chunk_uv) * w1 +
sample_layer(layers.z, chunk_uv) * w2 +
sample_layer(layers.w, chunk_uv) * w3;
vec3 n = normalize(NORMAL);
if (!FRONT_FACING) {
+75 -7
View File
@@ -21,8 +21,11 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
return root
var textures: PackedStringArray = data.get("textures", PackedStringArray())
var texture_types: PackedInt32Array = data.get("texture_types", PackedInt32Array())
var texture_flags: PackedInt32Array = data.get("texture_flags", PackedInt32Array())
var materials: Array = data.get("materials", [])
var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array())
var model_path: String = str(data.get("model_path", ""))
var mesh := ArrayMesh.new()
@@ -55,7 +58,17 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
var surf_idx := mesh.get_surface_count() - 1
var mat_def: Dictionary = materials[mat_id] if mat_id >= 0 and mat_id < materials.size() else {}
mesh.surface_set_material(surf_idx, _build_material(mat_def, tc_idx, textures, tex_combos, extracted_dir))
mesh.surface_set_material(
surf_idx,
_build_material(
mat_def,
tc_idx,
textures,
texture_types,
texture_flags,
tex_combos,
extracted_dir,
model_path))
if mesh.get_surface_count() == 0:
return root
@@ -71,36 +84,91 @@ static func _build_material(
mat_def: Dictionary,
texture_combo_index: int,
textures: PackedStringArray,
texture_types: PackedInt32Array,
texture_flags: PackedInt32Array,
tex_combos: PackedInt32Array,
extracted_dir: String) -> StandardMaterial3D:
extracted_dir: String,
model_path: String) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var flags: int = int(mat_def.get("flags", 0))
mat.cull_mode = BaseMaterial3D.CULL_DISABLED if (flags & 0x04) != 0 else BaseMaterial3D.CULL_BACK
mat.roughness = 0.85
mat.metallic = 0.0
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED if (flags & 0x01) != 0 else BaseMaterial3D.SHADING_MODE_PER_PIXEL
mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
mat.vertex_color_use_as_albedo = false
mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, false)
var blend_mode: int = mat_def.get("blend_mode", 0)
match blend_mode:
0: mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
1: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
_: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
0:
mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
1:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.5
3, 4, 6:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY
_:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY
if texture_combo_index >= 0 and texture_combo_index < tex_combos.size():
var tex_idx: int = tex_combos[texture_combo_index]
if tex_idx >= 0 and tex_idx < textures.size():
var tex_path: String = str(textures[tex_idx]).replace("\\", "/")
var tex_type := int(texture_types[tex_idx]) if tex_idx < texture_types.size() else 0
var tex_flags := int(texture_flags[tex_idx]) if tex_idx < texture_flags.size() else 0
if tex_path.is_empty():
tex_path = _default_texture_path(model_path, tex_type)
if not tex_path.is_empty():
var tex := _load_texture(tex_path, extracted_dir)
if tex:
mat.albedo_texture = tex
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, (tex_flags & 0x03) != 0)
return mat
static func _default_texture_path(model_path: String, texture_type: int) -> String:
var normalized := model_path.replace("\\", "/")
var rel_model := _relative_extracted_model_path(normalized)
var rel_lower := rel_model.to_lower()
if rel_lower.begins_with("item/objectcomponents/weapon/") and texture_type == 2:
var stem := rel_model.get_file().get_basename()
if stem.to_lower() == "axe_1h_horde_a_01":
return "Item/ObjectComponents/Weapon/Axe_1H_Horde_A_01Gray.blp"
if not stem.is_empty():
return "Item/ObjectComponents/Weapon/%s.blp" % stem
if rel_lower == "creature/wolf/wolf.m2" or rel_lower == "creature/wolf/wolf.mdx":
if texture_type == 11:
return "Creature/Wolf/WolfSkinCoyote.blp"
if texture_type == 12:
return "Creature/Wolf/WolfSkinCoyoteAlpha.blp"
if rel_lower == "creature/boar/boar.m2" or rel_lower == "creature/boar/boar.mdx":
if texture_type == 11:
return "Creature/Boar/BoarSkinIvory.blp"
if rel_lower == "creature/kobold/kobold.m2" or rel_lower == "creature/kobold/kobold.mdx":
if texture_type == 11:
return "Creature/Kobold/koboldskinAlbino.blp"
if rel_lower == "creature/murloc/murloc.m2" or rel_lower == "creature/murloc/murloc.mdx":
if texture_type == 11:
return "Creature/Murloc/SahauginskinBlue.blp"
return ""
static func _relative_extracted_model_path(path: String) -> String:
var marker := "/data/extracted/"
var lower := path.to_lower()
var marker_pos := lower.find(marker)
if marker_pos >= 0:
return path.substr(marker_pos + marker.length())
return path
static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D:
if rel_path.is_empty() or extracted_dir.is_empty():
return null
+18 -15
View File
@@ -6,6 +6,7 @@
class_name WMOBuilder
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd")
const M2_RIGHT_YAW_OFFSET := PI * 0.5
const BUILD_OCCLUDERS := false
const OCCLUDER_MIN_TRIANGLES := 16
@@ -27,7 +28,7 @@ static func clear_caches() -> void:
_m2_prototype_cache.clear()
_m2_missing_cache.clear()
_texture_cache.clear()
_wmo_liquid_material_cache.clear()
WOW_LIQUID_MATERIAL.clear_cache()
# Returns a Node3D containing one MeshInstance3D per WMO group.
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
@@ -339,6 +340,7 @@ static func _build_material(
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.specular_mode = BaseMaterial3D.SPECULAR_DISABLED
mat.vertex_color_use_as_albedo = false
mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, true)
var blend_mode: int = mat_def.get("blend_mode", 0)
match blend_mode:
@@ -356,6 +358,7 @@ static func _build_material(
if tex:
mat.albedo_texture = tex
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC
mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, true)
mat.set_meta("wow_flags", mat_def.get("flags", 0))
mat.set_meta("wow_shader", mat_def.get("shader", 0))
@@ -363,7 +366,6 @@ static func _build_material(
return mat
static var _wmo_liquid_material_cache: Dictionary = {}
# WMO MLIQ → MeshInstance3D in WMO-local coordinates.
# Corner & vertex grid are in WMO-local (Z-up); convert to Godot Y-up using the
@@ -384,6 +386,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D:
return null
var verts := PackedVector3Array()
var nrms := PackedVector3Array()
var uvs := PackedVector2Array()
var indices := PackedInt32Array()
# Pre-compute Godot-space position for each grid vertex.
@@ -417,6 +421,14 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D:
verts.append(pos[i10])
verts.append(pos[i11])
verts.append(pos[i01])
nrms.append(Vector3.UP)
nrms.append(Vector3.UP)
nrms.append(Vector3.UP)
nrms.append(Vector3.UP)
uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles))))
uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty) / maxf(1.0, float(ytiles))))
uvs.append(Vector2(float(tx + 1) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles))))
uvs.append(Vector2(float(tx) / maxf(1.0, float(xtiles)), float(ty + 1) / maxf(1.0, float(ytiles))))
# Two triangles, winding chosen to face up after the (-y, z, -x) flip.
indices.append(base + 0)
indices.append(base + 1)
@@ -431,6 +443,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D:
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_NORMAL] = nrms
arrays[Mesh.ARRAY_TEX_UV] = uvs
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
@@ -443,19 +457,8 @@ static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D:
return mi
static func _wmo_liquid_material(material_id: int) -> StandardMaterial3D:
if _wmo_liquid_material_cache.has(material_id):
return _wmo_liquid_material_cache[material_id]
var mat := StandardMaterial3D.new()
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
# Default: bluish water. Without DBC liquid_type lookup we can't pick magma/slime
# precisely yet — material_id from MLIQ is the WMO MOMT slot, not the DBC id.
mat.albedo_color = Color(0.2, 0.4, 0.7, 0.6)
_wmo_liquid_material_cache[material_id] = mat
return mat
static func _wmo_liquid_material(material_id: int) -> Material:
return WOW_LIQUID_MATERIAL.build_wmo(material_id)
static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D:
@@ -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
+18 -1
View File
@@ -180,6 +180,7 @@ Ref<Image> BLPLoader::parse(const uint8_t *data, size_t len) {
const size_t palette_ofs = sizeof(BLP2Header);
if (palette_ofs + 256*4 > len) return Ref<Image>();
const uint8_t *palette = data + palette_ofs;
const uint8_t *alpha = mip + (size_t)w * (size_t)h;
rgba.resize(w * h * 4);
for (uint32_t i = 0; i < w * h && i < mip_size; ++i) {
@@ -187,7 +188,23 @@ Ref<Image> BLPLoader::parse(const uint8_t *data, size_t len) {
rgba[i*4+0] = palette[idx*4+2]; // R (from BGR)
rgba[i*4+1] = palette[idx*4+1]; // G
rgba[i*4+2] = palette[idx*4+0]; // B
rgba[i*4+3] = (hdr.alphaDepth == 0) ? 255 : mip[w*h + i];
uint8_t a = 255;
if (hdr.alphaDepth == 8) {
size_t alpha_pos = (size_t)w * (size_t)h + i;
a = alpha_pos < mip_size ? mip[alpha_pos] : 255;
} else if (hdr.alphaDepth == 4) {
size_t byte_pos = i >> 1;
if ((size_t)w * (size_t)h + byte_pos < mip_size) {
uint8_t nibble = (i & 1) ? (alpha[byte_pos] >> 4) : (alpha[byte_pos] & 0x0F);
a = (uint8_t)(nibble * 17);
}
} else if (hdr.alphaDepth == 1) {
size_t byte_pos = i >> 3;
if ((size_t)w * (size_t)h + byte_pos < mip_size) {
a = (alpha[byte_pos] & (1 << (i & 7))) ? 255 : 0;
}
}
rgba[i*4+3] = a;
}
} else if (hdr.encoding == 3) {
+7
View File
@@ -199,6 +199,8 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
// ── Textures ─────────────────────────────────────────────────────────────
PackedStringArray textures;
PackedInt32Array texture_types;
PackedInt32Array texture_flags;
const auto *tex_arr = safe_array<M2Texture>(buf, hdr.ofsTextures, hdr.nTextures);
if (tex_arr) {
for (uint32_t i = 0; i < hdr.nTextures; ++i) {
@@ -217,6 +219,8 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
}
}
textures.push_back(to_godot(fname));
texture_types.push_back((int)t.type);
texture_flags.push_back((int)t.flags);
}
}
@@ -313,8 +317,11 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
Dictionary result;
result["textures"] = textures;
result["texture_types"] = texture_types;
result["texture_flags"] = texture_flags;
result["materials"] = materials;
result["texture_combos"] = texture_combos;
result["model_path"] = to_godot(path);
result["vertices"] = vertices;
result["normals"] = normals;
result["uvs"] = uvs;
+3
View File
@@ -24,8 +24,11 @@ namespace godot {
// Return Dictionary:
// {
// "textures": PackedStringArray, # .blp texture paths (may be empty for replaceable)
// "texture_types": PackedInt32Array, # M2Texture.type per texture slot
// "texture_flags": PackedInt32Array, # M2Texture.flags per texture slot
// "materials": Array[Dictionary], # [{flags, blend_mode}]
// "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures
// "model_path": String, # absolute source .m2 path
// "vertices": PackedVector3Array, # Godot-space positions
// "normals": PackedVector3Array,
// "uvs": PackedVector2Array,
+1 -1
View File
@@ -313,7 +313,7 @@ Dictionary WMOLoader::parse_group(const std::vector<uint8_t> &buf) {
auto *v = sc.array<MOTVEntry>();
uvs.resize(n);
for (uint32_t i = 0; i < n; ++i)
uvs[i] = Vector2(v[i].u, 1.0f - v[i].v);
uvs[i] = Vector2(v[i].u, v[i].v);
} else if (sc.is("MOVI")) {
uint32_t n = sc.size / 2;
+868
View File
@@ -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("/")
+1
View File
@@ -0,0 +1 @@
uid://didjth34ut0v1
@@ -1,7 +1,21 @@
[gd_scene format=3 uid="uid://f1nqi4emji47"]
[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_stream"]
[ext_resource type="Script" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"]
[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_player"]
[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_sky"]
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
height = 2.1
[sub_resource type="StandardMaterial3D" id="PlayerMaterial_1"]
albedo_color = Color(0.25, 0.42, 0.85, 1)
roughness = 0.65
[sub_resource type="CapsuleMesh" id="PlayerCapsuleMesh_1"]
material = SubResource("PlayerMaterial_1")
radius = 0.45
height = 2.1
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_1"]
sky_top_color = Color(0.13, 0.32, 0.62, 1)
@@ -14,20 +28,18 @@ ground_curve = 0.12
ground_energy_multiplier = 0.55
sun_angle_max = 12.0
sun_curve = 0.08
use_debanding = true
[sub_resource type="Sky" id="Sky_1"]
sky_material = SubResource("ProceduralSkyMaterial_1")
radiance_size = 3
[sub_resource type="Environment" id="Environment_1"]
background_mode = 2
sky = SubResource("Sky_1")
background_energy_multiplier = 0.9
sky = SubResource("Sky_1")
ambient_light_source = 3
ambient_light_color = Color(0.72, 0.8, 0.88, 1)
ambient_light_energy = 0.75
ambient_light_sky_contribution = 0.65
ambient_light_energy = 0.75
reflected_light_source = 2
fog_enabled = true
fog_mode = 1
@@ -36,23 +48,9 @@ fog_light_energy = 0.55
fog_sun_scatter = 0.08
fog_density = 0.00018
fog_sky_affect = 0.35
fog_depth_curve = 1.0
fog_depth_begin = 1200.0
fog_depth_end = 5200.0
[sub_resource type="StandardMaterial3D" id="PlayerMaterial_1"]
albedo_color = Color(0.25, 0.42, 0.85, 1)
roughness = 0.65
[sub_resource type="CapsuleMesh" id="PlayerCapsuleMesh_1"]
material = SubResource("PlayerMaterial_1")
radius = 0.45
height = 2.1
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
radius = 0.45
height = 2.1
[node name="StreamingWorld" type="Node3D" unique_id=1063159974]
script = ExtResource("1_stream")
camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D")
@@ -65,7 +63,7 @@ m2_build_groups_per_tick = 1
m2_multimesh_batch_size = 64
wmo_render_group_ops_per_tick = 16
cached_tile_mesh_limit = 48
terrain_quality_mesh_cache_limit = 128
terrain_quality_mesh_cache_limit = 48
lod0_radius_chunks = 10
lod1_radius_chunks = 20
lod2_radius_chunks = 40
@@ -77,9 +75,7 @@ boundary_prefetch_threshold = 0.42
auto_position_camera = false
enable_occlusion_culling = false
terrain_full_quality_radius_tiles = 5
terrain_control_splat_primary = true
terrain_control_splat_hide_fallback = true
terrain_control_splat_hide_fallback_radius_tiles = 0
terrain_control_splat_radius_tiles = 5
max_active_terrain_control_splat_tiles = 121
terrain_control_splat_cache_finalize_ops_per_tick = 8
@@ -89,33 +85,30 @@ m2_tile_radius = 3
wmo_tile_radius = 5
m2_visibility_range = 1600.0
wmo_visibility_range = 3600.0
terrain_quality_log_enabled = true
terrain_quality_log_interval = 1.0
hitch_profiler_enabled = true
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 22666, 80, 15200)
script = ExtResource("2_player")
spawn_tile_x = 42
spawn_tile_y = 28
sprint_multiplier = 6.0
spawn_tile_x = 31
spawn_tile_y = 31
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer"]
position = Vector3(0, 1.05, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer" unique_id=1297880621]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
shape = SubResource("PlayerCapsuleShape_1")
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer"]
position = Vector3(0, 1.05, 0)
[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
mesh = SubResource("PlayerCapsuleMesh_1")
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer"]
position = Vector3(0, 1.7, 0)
[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot"]
position = Vector3(0, 0, 8)
[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8)
current = true
far = 50000.0
fov = 70.0
far = 50000.0
[node name="Sun" type="DirectionalLight3D" parent="." unique_id=1436804627]
transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 0, 0)
@@ -127,3 +120,9 @@ directional_shadow_max_distance = 2200.0
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=12906896]
environment = SubResource("Environment_1")
[node name="WowSkyController" type="Node" parent="." unique_id=1473026848]
script = ExtResource("3_sky")
target_path = NodePath("../ThirdPersonPlayer")
world_environment_path = NodePath("../WorldEnvironment")
sun_path = NodePath("../Sun")