diff --git a/RENDER.md b/RENDER.md index 2ef3cf8..ee30d7e 100644 --- a/RENDER.md +++ b/RENDER.md @@ -243,6 +243,140 @@ src/tools/m2_to_gltf.py - WoW material flags не полностью совпадают; - billboards/leaf bones пока не как в клиенте. +### M2 Animation Research + +Проверка по `reference/whoa`, `reference/open-realm` и `reference/wow.export` показала, что анимации M2 нельзя надежно тащить как обычный Godot/glTF skeletal mesh. + +Оригинальная схема M2: + +- для каждой кости считается матрица через pivot transform: translation/rotation/scale вокруг `bone.pivot`; +- parent chain считается вручную; +- каждый skin section использует свой bone palette через `bone_combo_index`; +- vertex bone indices интерпретируются относительно palette текущего skin section. + +Именно так делают: + +- `reference/whoa/src/model/CM2Model.cpp` - расчет `m_boneMatrices`; +- `reference/whoa/src/model/CM2SceneRender.cpp` - upload bone palette из `boneCombos`; +- `reference/open-realm/games/world-of-warcraft/renderer/m2/r_m2.c` - `M2_CalculateBoneMatrices` и `M2_UploadBatchBones`; +- `reference/wow.export/src/js/3D/renderers/M2RendererGL.js` - ручной pivot-based runtime skinning. + +Вывод: старый экспериментальный путь через cached GLB ломал композитные doodad M2, потому что анимировал node с bind-pivot translation напрямую. Конвертер переведен на схему `pivot node -> animated bone node`, аналогичную wow.export `modelsExportWithBonePrefix`. + +Runtime принимает для M2-анимаций только GLB с меткой: + +```text +asset.extras.openwc_m2_anim_schema = pivot_prefix_v1 +``` + +Это защищает мир от старых битых GLB cache. Точечно пересобраны тестовые animated M2: + +```text +data/cache/m2_glb/GryphonRoost01.glb +data/cache/m2_glb/Fish.glb +data/cache/m2_glb/eagle.glb +``` + +Ограничение runtime: + +```text +enable_m2_animated_instances = true +m2_animated_max_primitives = 3 +m2_animated_denylist_patterns = ["gryphonroost"] +m2_animated_allowlist_patterns = ["creature/fish/", "creature/eagle/", "world/critter/"] +``` + +`GryphonRoost` stays out of the animated GLB path. The cached GLB has `Stand` +tracks, but the current converter/runtime breaks its composite skin/material layout. +Use the native M2 runtime path for this class of doodad; the old GLB animation +path remains disabled for `gryphonroost`. + +Native M2 animation first pass for composite doodads: + +- `M2Loader.load_m2_animated()` parses M2 bones, Stand track keys, `.skin` bone lookup/palette data, and expanded skinned surfaces. +- `M2NativeAnimatedBuilder` builds a regular `ArrayMesh` and keeps the original per-surface skin data. +- `M2NativeAnimator` evaluates the selected Stand sequence and applies WoW-style bone matrices: + `T(pivot + translation) * R * S * T(-pivot) * parent`. +- Vertex influences are resolved through `.skin` local bone indices and the M2 `boneCombos` palette, matching the original section/batch renderer model used by WoW/whoa. +- `StreamingWorldLoader` routes only `gryphonroost` through this native path; simple critters still use the existing GLB allowlist and most M2 world props stay on static MultiMesh. +- Current implementation rebuilds the animated `ArrayMesh` on CPU. This is correct enough for the problematic rare doodads and gives us the same data layout that can later move to shader/GPU skinning. + +Причина: композитные doodad вроде `GryphonRoost` снова ломают визуал при GLB-анимации. До M2-native renderer все world placement M2 должны оставаться статическими. GLB-анимация оставлена только как вручную включаемый debug experiment через allowlist. + +Дополнительно static M2 cache lookup ищет both original-case and lowercase paths. Это важно для ADT paths вроде `world/GENERIC/...`, потому что запеченный nested cache лежит lowercase. Static path также не использует GLB с `pivot_prefix_v1`, чтобы animated test cache не мог подменить корректный `.tscn`. + +Полноценный M2-native animation renderer/cache все еще остается правильным долгосрочным решением для 1:1 поведения, particles/ribbons и производительности на большом количестве animated props. + +### Blizzlike Shader Research + +Идея для следующего visual fidelity слоя: уходить от Godot `StandardMaterial3D` для WoW assets к собственным WoW-style `ShaderMaterial`. + +Что видно по whoa: + +- M2 использует маленькие shader permutations вроде `Diffuse_T1`, `Diffuse_T2`, `Diffuse_Env`, `Combiners_Mod`, `Combiners_Add`, `Combiners_Mod2x`; +- material state задается отдельно: blend mode, alpha ref, culling, depth test/write; +- light model старый и ручной: ambient + diffuse + emissive + fog constants, а не PBR; +- texture stages учитывают wrap/clamp, texture transform matrices и env/sphere map. + +Практический вывод для проекта: + +- terrain уже ближе к WoW, потому что использует `ShaderMaterial` и ручной light/fog; +- M2/WMO пока используют `StandardMaterial3D`, поэтому выглядят не совсем blizzlike; +- следующий шаг - `wow_m2_material` / `wow_wmo_material` с WoW blend modes, alpha ref, combiners и ручным fog/light. + +### M2 ShaderMaterial First Pass + +Implemented file: `addons/mpq_extractor/loaders/wow_m2_material.gd`. + +`M2Builder._build_material()` now returns a WoW-style `ShaderMaterial` instead of `StandardMaterial3D`. The same path is reused by `M2NativeAnimatedBuilder`, so static M2 batches and the native animated `gryphonroost` path share material behavior. + +Current M2 shader layer: + +- maps M2 blend modes 0..6 to Godot shader variants: opaque, alpha-key, alpha blend, additive, mod and mod2x approximation; +- uses alpha reference values observed in `reference/whoa/src/model/CM2SceneRender.cpp`: 0 for opaque, about 0.878 for alpha-key, 1/255 for blended passes; +- applies M2 render flags for unlit (`0x01`), two-sided/culling (`0x04`), depth test (`0x08`) and depth write (`0x10`); +- keeps BLP mipmap generation and anisotropic sampler hints; +- respects M2 texture wrap flags by compiling repeat/clamp shader variants; +- `M2Loader` exposes UV2, `texture_coord_combos`, `texture_combiner_combos`, `m2_flags`, batch `shader_id`, `texture_count` and texture-coordinate combo indices; +- `M2Loader` also exposes `m2_colors`, `texture_weights`, `texture_transforms`, `texture_weight_combos` and `texture_transform_combos`; +- `M2Builder` now resolves the first two texture stages from texture combos and passes combiner state into `WowM2Material`; +- `WowM2Material` has a first-pass implementation for combiner ops: opaque, mod, decal/fade, add, mod2x, mod2xNA/addNA and a cheap env-map UV approximation; +- static M2 color/alpha tracks and texture weight tracks are applied to `mesh_color`, matching the `whoa` render path where batch alpha is multiplied by color alpha and texture weight; +- static texture transform translation/scale is applied per texture stage through shader UV transform uniforms; +- animated texture transform translation tracks are approximated as GPU UV scroll (`translation_speed * TIME`) per texture stage, without per-frame CPU material updates; +- native animated M2 rebuild path preserves UV2 and the same material combiner state. +- uses a simple old-client-style ambient + diffuse light model and disables PBR/specular behavior. + +Known limits: + +- only the first two texture stages are represented; +- texture transform translation animation is supported as linear UV scroll; rotation/scale texture animation and time-varying color/weight tracks are still future work; +- exact env/sphere map math, model emissive tracks and full WoW render-state parity are still future work; +- WMO has a matching first-pass shader layer, but still needs full material flag parity. + +### WMO ShaderMaterial First Pass + +Implemented file: `addons/mpq_extractor/loaders/wow_wmo_material.gd`. + +`WMOBuilder._build_material()` now returns a WoW-style `ShaderMaterial` instead of `StandardMaterial3D`. WMO group meshes already carry `Mesh.ARRAY_COLOR` from group vertex colors, so the shader treats WMO as mostly baked-lit geometry: + +- blend modes 0..2 map to opaque, alpha-key and alpha blend variants; +- alpha-key uses discard instead of transparent sorting; +- texture repeat stays enabled for WMO wall/floor tiling; +- BLP mipmap generation and anisotropic sampler hints are preserved; +- shader is `unshaded` and multiplies texture color by WMO vertex color with a small ambient floor; +- DBC zone light is applied as a soft tint only, so baked WMO vertex colors and texture albedo stay readable; +- `color2` is passed as a secondary/detail tint, and `texture2` is used as a fallback detail texture when `texture1` is absent; +- `WMOLoader` exposes MOMT `diffuse_color`, `emissive_color`, `texture1`, `texture2`, `flags2` and `color2`; +- `WMOBuilder` loads `texture1` as a conservative secondary detail/multiply layer and passes MOMT diffuse/emissive colors into the shader; +- culling is disabled like the previous WMO material path, which avoids missing interior/backface geometry. + +Known limits: + +- deeper MOMT shader semantics, exact secondary texture mode and `texture2` usage are still approximate; +- WMO fog/light constants are not wired into this material yet; +- this first pass prioritizes matching old-client baked lighting behavior over physical lighting. + ## WMO Rendering Цель: WMO не должны фризить сцену при догрузке, особенно большие здания/города. @@ -324,6 +458,31 @@ addons/mpq_extractor/loaders/wow_liquid_material.gd - LiquidType.dbc texture selection пока не используется; - waterfalls/flowing water требуют отдельного M2/material path. +## Shared WoW Shader Globals + +Current terrain/M2/WMO/liquid shaders are intentionally `unshaded`: Godot lighting is not a good match for old WoW material rules, M2 blend modes, WMO baked vertex colors and control-splat terrain. To keep them visually tied to the active zone without updating every material instance, `WowSkyController` owns a shared global shader state: + +```text +wow_ambient_color +wow_light_color +wow_light_dir +wow_fog_color +wow_fog_range +wow_fog_density +wow_sun_elevation +``` + +These parameters are registered through `RenderingServer.global_shader_parameter_add` and updated from `Light.dbc` / `LightParams.dbc` sampling in `src/scenes/sky/wow_sky_controller.gd`. + +Consumers: + +- `addons/mpq_extractor/loaders/adt_builder.gd`: control-splat, fallback layered terrain and single-texture terrain. +- `addons/mpq_extractor/loaders/wow_m2_material.gd`: M2 combiner material uses zone ambient/diffuse light and distance fog. +- `addons/mpq_extractor/loaders/wow_wmo_material.gd`: WMO material keeps baked vertex lighting, but tints it by zone light and applies distance fog. +- `addons/mpq_extractor/loaders/wow_liquid_material.gd`: liquids use the shared fog color/range/density. + +The fog distance is computed from view-space position passed from `vertex()` to `fragment()`. This avoids per-camera material updates and keeps shader code compatible with Godot 4.6's spatial shader parser. + ## Sky And Lighting Добавлен `WowSkyController`: @@ -451,7 +610,7 @@ res://data/cache/wmo_render_v1 Baked terrain required: 4 Splat terrain required: 1 Control splat required: 3 -WMO streaming resource: 1 +WMO streaming resource: 2 ``` ## Debug Logs @@ -532,7 +691,7 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - M2 animation/skinning. - M2 particles/ribbons. -- Full WoW material flag parity. +- Full WoW material flag parity. Current first pass covers common M2 combiners and WMO shader ids 0, 3, 5, 6, 8, 9, 12, 13, 15, 16, 17 approximately. - True WMO portal/room culling. - Selective cheap dynamic shadows. - Liquid depth fade/shore blending. @@ -540,6 +699,42 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Better skybox transition validation per zone. - Automated renderer regression scenes/screenshots. +## 2026-07-04 WMO Shader Pass + +- WMO materials now consume shared `wow_light_dir` and `wow_sun_elevation` shader globals. +- Cached WMO meshes are refreshed at attach time, so already baked render-cache meshes can receive the latest WMO material shader without immediately rebuilding the whole WMO cache. +- The WMO shader now uses normals for a cheap WoW-style ambient + directional lighting term instead of relying only on texture * vertex color. +- Added approximate pixel shader branches for common WMO modes: diffuse, env, env metal, two-layer diffuse, terrain-style two-layer, diffuse emissive and mod2x variants. +- Still missing for true parity: second/third WMO vertex color streams, extra WMO texture coordinate sets, third+ texture samplers, exact env-map coordinate generation and portal/room visibility. + +## 2026-07-04 M2 Shader Mapping Pass + +- M2 material build now maps raw skin `shaderID` through the same vertex/pixel shader selection rules used by `wow.export`. +- `WowM2Material` receives `vertex_shader_id` and `pixel_shader_id` uniforms instead of relying only on generic two-stage ops. +- Vertex shader ids 0..18 now drive UV source selection, UV2 use, environment coordinates and edge-fade modes. +- Pixel shader ids 0..29 have first-pass two-texture combiner behavior for the common opaque/mod/add/mod2x/alpha cases. +- M2 materials now load and bind up to four texture combo entries. This enables first-pass support for glow/specular/crossfade/guild/const combiner cases that need `tex3` or `tex4`. +- Texture weight combos are exposed to the shader as `tex_sample_alpha`, matching the common WoW combiner weight slots. +- Native animated M2 surfaces use the same `_build_material()` path, so this also applies to runtime-animated M2 objects. +- Debug metadata is stored on materials: raw `wow_m2_shader_id`, mapped `wow_m2_vertex_shader_id`, and mapped `wow_m2_pixel_shader_id`. +- Transparent M2 passes such as waterfalls now avoid depth writes/prepass (`depth_draw_never`). This prevents layered alpha planes from self-occluding into white/solid webbing while keeping cutout foliage on the opaque-depth path. +- Scrolling alpha-blend M2 models with `waterfall` in the path get a conservative soft-alpha profile: lower opacity, stronger alpha falloff and UV-edge fade. This hides the rectangular card edges without changing normal foliage/cutout props. +- `.skin` texture unit `priority` is exposed as `priority_plane` for future transparent-pass ordering. This needs the native GDExtension DLL to be rebuilt when the editor is not holding it open. +- Waterfalls seen near terrain cliffs are usually WMO embedded doodads rather than ADT `MDDF` placements. WMO doodad transforms now use the converted MODD quaternion directly, without the old extra `+90 deg` M2 yaw offset. +- WMO render/scene caches now carry version guards for this transform path. Rebuild `wmo_render_v1`/`wmo_tscn` after changing WMO doodad placement code; old caches are ignored by runtime. +- Elwynn/Thunder Falls style outdoor waterfalls are ADT `MDDF` M2 placements. Regular ADT M2 doodads use the native cached Godot-space Euler directly via `Basis.from_euler(rot)`; do not apply a global yaw/order remap, because it flips ordinary props such as wagons, stalls, barrels, fences and trees. `ElwynnCliffRock01/02` are a model-specific exception: these are open-backed shell meshes, so they use a narrow `YZX` placement correction with `rot.y + 180 deg` to push the hollow side into the terrain. The waterfall transform correction is also filename-allowlisted to the actual sheet M2s (`NewWaterfall`, `ElwynnTallWaterfall01`). Sheet M2s keep the calibrated world-space `+90 deg` Godot-Y yaw to anchor the top to the upper pool, plus model-specific twist where needed. Tall waterfall sheets twist around their local fall line with an origin offset so top/bottom anchors stay fixed instead of rotating around the model pivot; `ElwynnTallWaterfall01` uses the opposite twist sign from `NewWaterfall` to face the correct side. Do not apply the raw-M2/WoWee render-space basis here; that double-converts M2 vertices. Do not cull boundary waterfall placements by `floor(world_pos / TILE_SIZE)`: tiles can carry a small cross-border placement that is still the active streamed instance at low M2 radius. Future cross-tile dedupe should use `MDDF.uniqueId` plus an ADT cache format bump/rebake. +- Still missing for true parity: exact shader array entries beyond the common WotLK set, particles/ribbons, full edge-scan tuning, exact guild/color constants and specialized blend-state edge cases. + +## 2026-07-07 World Shader Light Stabilization + +- DBC sky colors remain the source for `Environment` and sky rendering, but material shader globals now receive desaturated/neutralized ambient, diffuse and fog colors. This prevents Elwynn daylight profiles from tinting all ADT/M2/WMO albedo cyan or green. +- ADT terrain shaders now transform normals with `MODEL_NORMAL_MATRIX` before comparing them against the world-space `wow_light_dir`. Using fragment `NORMAL` directly made terrain lighting depend on the local surface basis and produced blue/green patches on slopes and roads. +- ADT, M2 and WMO material fog clamps `wow_fog_density` before mixing into `ALBEDO`; fog should add distance haze, not repaint nearby assets. +- M2/WMO shaders now reduce zone-color strength and clamp final albedo/specular/emissive contribution. Additive/parity edge cases still need exact WoW pass ordering later, but ordinary Elwynn props should no longer produce overexposed or acid-green surfaces. +- Human building WMO materials such as `HumanTwoStory` and `GoldshireInn` carry strong painted `MOMT.diffColor` values, including green trim colors. Textured WMO surfaces now use this as a soft hue tint instead of multiplying texture albedo by the raw RGB value; untextured WMO surfaces still use a muted material paint color. +- WMO window materials such as `MM_ELWYNN_WND_EXT__01.BLP` carry high `MOMT.sidnColor`/emissive values. Emissive is texture-masked and capped for window paths so it brightens the pane pattern instead of flooding the whole quad white. +- Runtime WMO cache material refresh is versioned separately from WMO geometry cache. Shader-only WMO material fixes should bump `WMO_MATERIAL_REFRESH_VERSION` instead of forcing a full `wmo_render_v1` rebake. + ## 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. diff --git a/addons/mpq_extractor/bin/libmpq_extractor.windows.x86_64.pdb b/addons/mpq_extractor/bin/libmpq_extractor.windows.x86_64.pdb new file mode 100644 index 0000000..0627b8c Binary files /dev/null and b/addons/mpq_extractor/bin/libmpq_extractor.windows.x86_64.pdb differ diff --git a/addons/mpq_extractor/bin/~libmpq_extractor.windows.x86_64.dll~RF4867c24.TMP b/addons/mpq_extractor/bin/~libmpq_extractor.windows.x86_64.dll~RF4867c24.TMP new file mode 100644 index 0000000..ea55558 Binary files /dev/null and b/addons/mpq_extractor/bin/~libmpq_extractor.windows.x86_64.dll~RF4867c24.TMP differ diff --git a/addons/mpq_extractor/loaders/adt_builder.gd b/addons/mpq_extractor/loaders/adt_builder.gd index 87b8ecb..11a6afd 100644 --- a/addons/mpq_extractor/loaders/adt_builder.gd +++ b/addons/mpq_extractor/loaders/adt_builder.gd @@ -21,6 +21,10 @@ static var _baked_texture_material_cache: Dictionary = {} static var _alpha_texture_cache: Dictionary = {} static var _layered_material_cache: Dictionary = {} + +static func _ensure_wow_shader_globals() -> void: + pass + ## Build a Node3D with 256 terrain chunk meshes. ## `extracted_dir` must be an absolute path to the extracted/ folder so BLPs can be loaded. static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: @@ -1253,6 +1257,7 @@ static func _get_control_splat_shader() -> Shader: if _control_splat_shader: return _control_splat_shader + _ensure_wow_shader_globals() _control_splat_shader = Shader.new() _control_splat_shader.code = """ shader_type spatial; @@ -1269,10 +1274,34 @@ uniform float ambient = 0.62; uniform float diffuse = 0.38; uniform float hemi = 0.12; +global uniform vec4 wow_ambient_color; +global uniform vec4 wow_light_color; +global uniform vec3 wow_light_dir; +global uniform vec4 wow_fog_color; +global uniform vec2 wow_fog_range; +global uniform float wow_fog_density; + +varying vec3 world_pos; +varying vec3 world_normal; +varying vec3 view_pos; + +void vertex() { + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL); + view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + vec3 sample_layer(float layer, vec2 continuous_chunk_uv) { return texture(terrain_tex, vec3(continuous_chunk_uv * uv_scale, layer), mip_bias).rgb; } +vec3 apply_wow_fog(vec3 color) { + float dist = length(view_pos); + float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist); + float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55); + return mix(color, wow_fog_color.rgb, fog_amount); +} + void fragment() { vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999)); vec2 chunk_uv = tile_uv * 16.0; @@ -1300,15 +1329,18 @@ void fragment() { sample_layer(layers.z, chunk_uv) * w2 + sample_layer(layers.w, chunk_uv) * w3; - vec3 n = normalize(NORMAL); + vec3 n = normalize(world_normal); if (!FRONT_FACING) { n = -n; } - vec3 l = normalize(light_dir); + vec3 l = normalize(wow_light_dir); float ndl = max(dot(n, l), 0.0); float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); - float light = ambient + diffuse * ndl + hemi * hemi_term; - ALBEDO = albedo * light; + vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22); + vec3 light_tint = mix(vec3(1.0), wow_light_color.rgb, 0.12); + vec3 light = ambient_tint * ambient + light_tint * diffuse * ndl + vec3(hemi * hemi_term); + light = clamp(light, vec3(0.48), vec3(1.08)); + ALBEDO = apply_wow_fog(albedo * light); } """ return _control_splat_shader @@ -1465,6 +1497,7 @@ static func _get_terrain_shader() -> Shader: if _terrain_shader: return _terrain_shader + _ensure_wow_shader_globals() _terrain_shader = Shader.new() _terrain_shader.code = """ shader_type spatial; @@ -1487,6 +1520,30 @@ uniform float ambient = 0.62; uniform float diffuse = 0.38; uniform float hemi = 0.12; +global uniform vec4 wow_ambient_color; +global uniform vec4 wow_light_color; +global uniform vec3 wow_light_dir; +global uniform vec4 wow_fog_color; +global uniform vec2 wow_fog_range; +global uniform float wow_fog_density; + +varying vec3 world_pos; +varying vec3 world_normal; +varying vec3 view_pos; + +void vertex() { + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL); + view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +vec3 apply_wow_fog(vec3 color) { + float dist = length(view_pos); + float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist); + float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55); + return mix(color, wow_fog_color.rgb, fog_amount); +} + void fragment() { vec2 tiled_uv = UV * uv_scale; @@ -1516,15 +1573,18 @@ void fragment() { if (layer_count > 2) { albedo += c2 * w2; } if (layer_count > 3) { albedo += c3 * w3; } - vec3 n = normalize(NORMAL); + vec3 n = normalize(world_normal); if (!FRONT_FACING) { n = -n; } - vec3 l = normalize(light_dir); + vec3 l = normalize(wow_light_dir); float ndl = max(dot(n, l), 0.0); float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); - float light = ambient + diffuse * ndl + hemi * hemi_term; - ALBEDO = albedo.rgb * light; + vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22); + vec3 light_tint = mix(vec3(1.0), wow_light_color.rgb, 0.12); + vec3 light = ambient_tint * ambient + light_tint * diffuse * ndl + vec3(hemi * hemi_term); + light = clamp(light, vec3(0.48), vec3(1.08)); + ALBEDO = apply_wow_fog(albedo.rgb * light); } """ return _terrain_shader @@ -1534,6 +1594,7 @@ static func _get_single_texture_shader() -> Shader: if _single_texture_shader: return _single_texture_shader + _ensure_wow_shader_globals() _single_texture_shader = Shader.new() _single_texture_shader.code = """ shader_type spatial; @@ -1548,6 +1609,23 @@ uniform float ambient = 0.62; uniform float diffuse = 0.38; uniform float hemi = 0.12; +global uniform vec4 wow_ambient_color; +global uniform vec4 wow_light_color; +global uniform vec3 wow_light_dir; +global uniform vec4 wow_fog_color; +global uniform vec2 wow_fog_range; +global uniform float wow_fog_density; + +varying vec3 world_pos; +varying vec3 world_normal; +varying vec3 view_pos; + +void vertex() { + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL); + view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + vec3 sample_sharp(sampler2D tex, vec2 uv) { vec3 center = texture(tex, uv, mip_bias).rgb; if (sharpen_strength <= 0.001) { @@ -1564,16 +1642,26 @@ vec3 sample_sharp(sampler2D tex, vec2 uv) { return clamp(center * (1.0 + 4.0 * sharpen_strength) - side * sharpen_strength, vec3(0.0), vec3(1.0)); } +vec3 apply_wow_fog(vec3 color) { + float dist = length(view_pos); + float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist); + float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55); + return mix(color, wow_fog_color.rgb, fog_amount); +} + void fragment() { - vec3 n = normalize(NORMAL); + vec3 n = normalize(world_normal); if (!FRONT_FACING) { n = -n; } - vec3 l = normalize(light_dir); + vec3 l = normalize(wow_light_dir); float ndl = max(dot(n, l), 0.0); float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); - float light = ambient + diffuse * ndl + hemi * hemi_term; - ALBEDO = sample_sharp(tex0, UV * uv_scale) * light; + vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22); + vec3 light_tint = mix(vec3(1.0), wow_light_color.rgb, 0.12); + vec3 light = ambient_tint * ambient + light_tint * diffuse * ndl + vec3(hemi * hemi_term); + light = clamp(light, vec3(0.48), vec3(1.08)); + ALBEDO = apply_wow_fog(sample_sharp(tex0, UV * uv_scale) * light); } """ return _single_texture_shader diff --git a/addons/mpq_extractor/loaders/m2_builder.gd b/addons/mpq_extractor/loaders/m2_builder.gd index 14190fd..27cfbb6 100644 --- a/addons/mpq_extractor/loaders/m2_builder.gd +++ b/addons/mpq_extractor/loaders/m2_builder.gd @@ -5,6 +5,8 @@ ## add_child(node) class_name M2Builder +const WOW_M2_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_m2_material.gd") + static var _texture_cache: Dictionary = {} static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: @@ -14,6 +16,7 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: var verts: PackedVector3Array = data.get("vertices", PackedVector3Array()) var normals: PackedVector3Array = data.get("normals", PackedVector3Array()) var uvs: PackedVector2Array = data.get("uvs", PackedVector2Array()) + var uvs2: PackedVector2Array = data.get("uvs2", PackedVector2Array()) var indices: PackedInt32Array = data.get("indices", PackedInt32Array()) var batches: Array = data.get("batches", []) @@ -25,6 +28,14 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: 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 tex_coord_combos: PackedInt32Array = data.get("texture_coord_combos", PackedInt32Array()) + var tex_weight_combos: PackedInt32Array = data.get("texture_weight_combos", PackedInt32Array()) + var tex_transform_combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array()) + var tex_combiner_combos: PackedInt32Array = data.get("texture_combiner_combos", PackedInt32Array()) + var m2_colors: Array = data.get("m2_colors", []) + var tex_weights: PackedFloat32Array = data.get("texture_weights", PackedFloat32Array()) + var tex_transforms: Array = data.get("texture_transforms", []) + var m2_flags: int = int(data.get("m2_flags", 0)) var model_path: String = str(data.get("model_path", "")) var mesh := ArrayMesh.new() @@ -52,6 +63,8 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: arrays[Mesh.ARRAY_NORMAL] = normals if uvs.size() == verts.size(): arrays[Mesh.ARRAY_TEX_UV] = uvs + if uvs2.size() == verts.size(): + arrays[Mesh.ARRAY_TEX_UV2] = uvs2 arrays[Mesh.ARRAY_INDEX] = batch_indices mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) @@ -67,8 +80,17 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: texture_types, texture_flags, tex_combos, + tex_coord_combos, + tex_weight_combos, + tex_transform_combos, + tex_combiner_combos, + m2_colors, + tex_weights, + tex_transforms, + m2_flags, extracted_dir, - model_path)) + model_path, + batch)) if mesh.get_surface_count() == 0: return root @@ -87,52 +109,342 @@ static func _build_material( texture_types: PackedInt32Array, texture_flags: PackedInt32Array, tex_combos: PackedInt32Array, + tex_coord_combos: PackedInt32Array, + tex_weight_combos: PackedInt32Array, + tex_transform_combos: PackedInt32Array, + tex_combiner_combos: PackedInt32Array, + m2_colors: Array, + tex_weights: PackedFloat32Array, + tex_transforms: Array, + m2_flags: int, extracted_dir: String, - model_path: String) -> StandardMaterial3D: + model_path: String, + batch: Dictionary = {}) -> Material: - var mat := StandardMaterial3D.new() 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 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.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 + var tex_flags := 0 + var tex: Texture2D = null + var tex2: Texture2D = null + var tex3: Texture2D = null + var tex4: Texture2D = null + var tex2_flags := 0 + var tex3_flags := 0 + var tex4_flags := 0 - 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) + var resolved0 := _resolve_combo_texture(texture_combo_index, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) + tex = resolved0.get("texture", null) + tex_flags = int(resolved0.get("flags", 0)) + var texture_count := maxi(1, int(batch.get("texture_count", 1))) + if texture_count > 1: + var resolved1 := _resolve_combo_texture(texture_combo_index + 1, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) + tex2 = resolved1.get("texture", null) + tex2_flags = int(resolved1.get("flags", 0)) + if texture_count > 2: + var resolved2 := _resolve_combo_texture(texture_combo_index + 2, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) + tex3 = resolved2.get("texture", null) + tex3_flags = int(resolved2.get("flags", 0)) + if texture_count > 3: + var resolved3 := _resolve_combo_texture(texture_combo_index + 3, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path) + tex4 = resolved3.get("texture", null) + tex4_flags = int(resolved3.get("flags", 0)) + var combiner := _resolve_combiner_state(blend_mode, texture_count, batch, tex_coord_combos, tex_combiner_combos, m2_flags) + combiner["mesh_color"] = _resolve_mesh_color(batch, m2_colors, tex_weights, tex_weight_combos) + combiner["tex_sample_alpha"] = _resolve_texture_sample_alpha(batch, tex_weights, tex_weight_combos) + combiner["stage0_uv_transform"] = _resolve_texture_transform(batch, 0, tex_transform_combos, tex_transforms) + combiner["stage1_uv_transform"] = _resolve_texture_transform(batch, 1, tex_transform_combos, tex_transforms) + combiner["stage2_uv_transform"] = _resolve_texture_transform(batch, 2, tex_transform_combos, tex_transforms) + combiner["stage3_uv_transform"] = _resolve_texture_transform(batch, 3, tex_transform_combos, tex_transforms) + combiner["stage0_uv_scroll"] = _resolve_texture_scroll(batch, 0, tex_transform_combos, tex_transforms) + combiner["stage1_uv_scroll"] = _resolve_texture_scroll(batch, 1, tex_transform_combos, tex_transforms) + combiner["stage2_uv_scroll"] = _resolve_texture_scroll(batch, 2, tex_transform_combos, tex_transforms) + combiner["stage3_uv_scroll"] = _resolve_texture_scroll(batch, 3, tex_transform_combos, tex_transforms) + combiner["vertex_shader_id"] = _m2_vertex_shader_id(texture_count, int(batch.get("shader_id", 0))) + combiner["pixel_shader_id"] = _m2_pixel_shader_id(texture_count, int(batch.get("shader_id", 0))) + combiner["priority_plane"] = int(batch.get("priority_plane", 0)) + if _is_soft_waterfall_material(model_path, blend_mode, combiner): + combiner["effect_alpha_scale"] = 1.12 + combiner["effect_alpha_power"] = 0.92 + combiner["uv_edge_fade_strength"] = 0.22 + + var mat := WOW_M2_MATERIAL.build( + tex, + tex2, + tex3, + tex4, + flags, + blend_mode, + tex_flags, + tex2_flags, + tex3_flags, + tex4_flags, + model_path.get_file().get_basename(), + combiner) + mat.set_meta("wow_flags", flags) + mat.set_meta("wow_blend_mode", blend_mode) + mat.set_meta("wow_m2_shader_id", int(batch.get("shader_id", 0))) + mat.set_meta("wow_m2_vertex_shader_id", int(combiner.get("vertex_shader_id", 0))) + mat.set_meta("wow_m2_pixel_shader_id", int(combiner.get("pixel_shader_id", 0))) + mat.set_meta("wow_priority_plane", int(batch.get("priority_plane", 0))) return mat +static func _is_soft_waterfall_material(model_path: String, blend_mode: int, combiner: Dictionary) -> bool: + if blend_mode != 2: + return false + var lower_path := model_path.to_lower() + if not lower_path.contains("waterfall"): + return false + var scroll0: Vector2 = combiner.get("stage0_uv_scroll", Vector2.ZERO) + var scroll1: Vector2 = combiner.get("stage1_uv_scroll", Vector2.ZERO) + return scroll0.length_squared() > 0.000001 or scroll1.length_squared() > 0.000001 + + +static func _m2_vertex_shader_id(texture_count: int, shader_id: int) -> int: + if (shader_id & 0x8000) != 0: + return _m2_shader_array_vertex_id(shader_id & 0x7fff) + if texture_count == 1: + if (shader_id & 0x80) != 0: + return 1 # Diffuse_Env + if (shader_id & 0x4000) != 0: + return 10 # Diffuse_T2 + return 0 # Diffuse_T1 + + if (shader_id & 0x80) != 0: + if (shader_id & 0x8) != 0: + return 5 # Diffuse_Env_Env + return 4 # Diffuse_Env_T1 + if (shader_id & 0x8) != 0: + return 3 # Diffuse_T1_Env + if (shader_id & 0x4000) != 0: + return 2 # Diffuse_T1_T2 + return 7 # Diffuse_T1_T1 + + +static func _m2_pixel_shader_id(texture_count: int, shader_id: int) -> int: + if (shader_id & 0x8000) != 0: + return _m2_shader_array_pixel_id(shader_id & 0x7fff) + if texture_count == 1: + return 1 if (shader_id & 0x70) != 0 else 0 + + if (shader_id & 0x70) != 0: + match shader_id & 7: + 3: + return 8 # Combiners_Mod_Add + 4: + return 7 # Combiners_Mod_Mod2x + 6: + return 9 # Combiners_Mod_Mod2xNA + 7: + return 10 # Combiners_Mod_AddNA + _: + return 6 # Combiners_Mod_Mod + + match shader_id & 7: + 0: + return 5 # Combiners_Opaque_Opaque + 3, 7: + return 13 # Combiners_Opaque_AddAlpha + 4: + return 3 # Combiners_Opaque_Mod2x + 6: + return 4 # Combiners_Opaque_Mod2xNA + _: + return 2 # Combiners_Opaque_Mod + + +static func _m2_shader_array_vertex_id(shader_array_index: int) -> int: + match shader_array_index: + 0: + return 8 + 1: + return 0 + 2: + return 1 + 3: + return 1 + 4: + return 1 + 5: + return 1 + 6: + return 0 + 7: + return 12 + 8: + return 9 + 9: + return 12 + _: + return 0 + + +static func _m2_shader_array_pixel_id(shader_array_index: int) -> int: + match shader_array_index: + 0: + return 35 + 1: + return 0 + 2: + return 7 + 3: + return 1 + 4: + return 6 + 5: + return 4 + 6: + return 0 + 7: + return 7 + 8: + return 1 + 9: + return 6 + _: + return 0 + + +static func _resolve_combo_texture( + combo_index: int, + textures: PackedStringArray, + texture_types: PackedInt32Array, + texture_flags: PackedInt32Array, + tex_combos: PackedInt32Array, + extracted_dir: String, + model_path: String) -> Dictionary: + + if combo_index < 0 or combo_index >= tex_combos.size(): + return {} + var tex_idx: int = tex_combos[combo_index] + if tex_idx < 0 or tex_idx >= textures.size(): + return {} + 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) + var tex: Texture2D = null + if not tex_path.is_empty(): + tex = _load_texture(tex_path, extracted_dir) + return { + "texture": tex, + "flags": tex_flags, + "path": tex_path, + } + + +static func _resolve_combiner_state( + blend_mode: int, + texture_count: int, + batch: Dictionary, + tex_coord_combos: PackedInt32Array, + tex_combiner_combos: PackedInt32Array, + m2_flags: int) -> Dictionary: + + var state := { + "op0": 0 if blend_mode == 0 else 1, + "op1": 1, + "env0": false, + "env1": false, + "use_uv2_0": false, + "use_uv2_1": false, + } + var shader_id := int(batch.get("shader_id", 0)) + var coord_index := int(batch.get("texture_coord_combo_index", 0)) + if (m2_flags & 0x8) != 0: + for texture_index in mini(texture_count, 2): + var op := 0 if texture_index == 0 and blend_mode == 0 else 1 + var combiner_index := shader_id + texture_index + if combiner_index >= 0 and combiner_index < tex_combiner_combos.size(): + op = int(tex_combiner_combos[combiner_index]) + state["op%d" % texture_index] = op + _apply_texture_coord_state(state, texture_index, coord_index + texture_index, tex_coord_combos) + else: + _apply_texture_coord_state(state, 0, coord_index, tex_coord_combos) + return state + + +static func _apply_texture_coord_state(state: Dictionary, texture_index: int, combo_index: int, tex_coord_combos: PackedInt32Array) -> void: + if combo_index < 0 or combo_index >= tex_coord_combos.size(): + return + var coord := int(tex_coord_combos[combo_index]) + state["use_uv2_%d" % texture_index] = coord == 1 + state["env%d" % texture_index] = coord > 2 + + +static func _resolve_mesh_color( + batch: Dictionary, + m2_colors: Array, + tex_weights: PackedFloat32Array, + tex_weight_combos: PackedInt32Array) -> Color: + + var color := Color.WHITE + var color_index := int(batch.get("color_index", -1)) + if color_index >= 0 and color_index < m2_colors.size(): + var color_entry: Dictionary = m2_colors[color_index] if m2_colors[color_index] is Dictionary else {} + color = color_entry.get("color", Color.WHITE) + + var weight_combo_index := int(batch.get("texture_weight_combo_index", -1)) + if weight_combo_index >= 0 and weight_combo_index < tex_weight_combos.size(): + var weight_index := int(tex_weight_combos[weight_combo_index]) + if weight_index >= 0 and weight_index < tex_weights.size(): + color.a *= clampf(tex_weights[weight_index], 0.0, 1.0) + return color + + +static func _resolve_texture_sample_alpha( + batch: Dictionary, + tex_weights: PackedFloat32Array, + tex_weight_combos: PackedInt32Array) -> Vector3: + + var result := Vector3.ONE + var weight_combo_index := int(batch.get("texture_weight_combo_index", -1)) + for stage in 3: + var combo_index := weight_combo_index + stage + if combo_index < 0 or combo_index >= tex_weight_combos.size(): + continue + var weight_index := int(tex_weight_combos[combo_index]) + if weight_index >= 0 and weight_index < tex_weights.size(): + result[stage] = clampf(tex_weights[weight_index], 0.0, 1.0) + return result + + +static func _resolve_texture_transform( + batch: Dictionary, + stage: int, + tex_transform_combos: PackedInt32Array, + tex_transforms: Array) -> Vector4: + + var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage + if combo_index < 0 or combo_index >= tex_transform_combos.size(): + return Vector4(1.0, 1.0, 0.0, 0.0) + var transform_index := int(tex_transform_combos[combo_index]) + if transform_index < 0 or transform_index >= tex_transforms.size(): + return Vector4(1.0, 1.0, 0.0, 0.0) + var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {} + var scale: Vector2 = transform.get("scale", Vector2.ONE) + var translation: Vector2 = transform.get("translation", Vector2.ZERO) + return Vector4(scale.x, scale.y, translation.x, translation.y) + + +static func _resolve_texture_scroll( + batch: Dictionary, + stage: int, + tex_transform_combos: PackedInt32Array, + tex_transforms: Array) -> Vector2: + + var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage + if combo_index < 0 or combo_index >= tex_transform_combos.size(): + return Vector2.ZERO + var transform_index := int(tex_transform_combos[combo_index]) + if transform_index < 0 or transform_index >= tex_transforms.size(): + return Vector2.ZERO + var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {} + return transform.get("translation_speed", Vector2.ZERO) + + 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) diff --git a/addons/mpq_extractor/loaders/m2_native_animated_builder.gd b/addons/mpq_extractor/loaders/m2_native_animated_builder.gd new file mode 100644 index 0000000..00c2187 --- /dev/null +++ b/addons/mpq_extractor/loaders/m2_native_animated_builder.gd @@ -0,0 +1,101 @@ +## Builds a runtime-animated M2 node from native M2Loader data. +## M2 skinning is evaluated explicitly because WoW's pivot/palette model does +## not map cleanly to Godot Skeleton3D/Skin for composite doodads. +class_name M2NativeAnimatedBuilder + +const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") +const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") + + +static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: + var root := Node3D.new() + root.name = "M2NativeAnimated" + + var bones: Array = data.get("bones", []) + var surfaces: Array = data.get("animated_surfaces", []) + if bones.is_empty() or surfaces.is_empty(): + return root + + var mesh := _build_mesh(data, surfaces, extracted_dir) + if mesh == null or mesh.get_surface_count() <= 0: + return root + + var mesh_instance := MeshInstance3D.new() + mesh_instance.name = "Mesh" + mesh_instance.mesh = mesh + root.add_child(mesh_instance) + + var animator = M2_NATIVE_ANIMATOR_SCRIPT.new() + animator.name = "M2NativeAnimator" + root.add_child(animator) + animator.setup(mesh_instance, bones, surfaces, float(data.get("animation_length", 0.0))) + return root + + +static func _build_mesh(data: Dictionary, surfaces: Array, extracted_dir: String) -> ArrayMesh: + var mesh := ArrayMesh.new() + 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 tex_coord_combos: PackedInt32Array = data.get("texture_coord_combos", PackedInt32Array()) + var tex_weight_combos: PackedInt32Array = data.get("texture_weight_combos", PackedInt32Array()) + var tex_transform_combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array()) + var tex_combiner_combos: PackedInt32Array = data.get("texture_combiner_combos", PackedInt32Array()) + var m2_colors: Array = data.get("m2_colors", []) + var tex_weights: PackedFloat32Array = data.get("texture_weights", PackedFloat32Array()) + var tex_transforms: Array = data.get("texture_transforms", []) + var m2_flags: int = int(data.get("m2_flags", 0)) + var model_path := String(data.get("model_path", "")) + + for surface_variant in surfaces: + if not (surface_variant is Dictionary): + continue + var surface: Dictionary = surface_variant + var verts: PackedVector3Array = surface.get("vertices", PackedVector3Array()) + var indices: PackedInt32Array = surface.get("indices", PackedInt32Array()) + if verts.is_empty() or indices.is_empty(): + continue + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + var normals: PackedVector3Array = surface.get("normals", PackedVector3Array()) + if normals.size() == verts.size(): + arrays[Mesh.ARRAY_NORMAL] = normals + var uvs: PackedVector2Array = surface.get("uvs", PackedVector2Array()) + if uvs.size() == verts.size(): + arrays[Mesh.ARRAY_TEX_UV] = uvs + var uvs2: PackedVector2Array = surface.get("uvs2", PackedVector2Array()) + if uvs2.size() == verts.size(): + arrays[Mesh.ARRAY_TEX_UV2] = uvs2 + arrays[Mesh.ARRAY_INDEX] = indices + + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var surface_index := mesh.get_surface_count() - 1 + var material_id := int(surface.get("material_id", -1)) + var texture_combo_index := int(surface.get("texture_combo_index", -1)) + var mat_def: Dictionary = materials[material_id] if material_id >= 0 and material_id < materials.size() else {} + mesh.surface_set_material( + surface_index, + M2_BUILDER_SCRIPT._build_material( + mat_def, + texture_combo_index, + textures, + texture_types, + texture_flags, + tex_combos, + tex_coord_combos, + tex_weight_combos, + tex_transform_combos, + tex_combiner_combos, + m2_colors, + tex_weights, + tex_transforms, + m2_flags, + extracted_dir, + model_path, + surface)) + + return mesh diff --git a/addons/mpq_extractor/loaders/m2_native_animated_builder.gd.uid b/addons/mpq_extractor/loaders/m2_native_animated_builder.gd.uid new file mode 100644 index 0000000..17e95a7 --- /dev/null +++ b/addons/mpq_extractor/loaders/m2_native_animated_builder.gd.uid @@ -0,0 +1 @@ +uid://dngobeqkb20pc diff --git a/addons/mpq_extractor/loaders/wmo_builder.gd b/addons/mpq_extractor/loaders/wmo_builder.gd index 751c988..7b90cee 100644 --- a/addons/mpq_extractor/loaders/wmo_builder.gd +++ b/addons/mpq_extractor/loaders/wmo_builder.gd @@ -7,7 +7,9 @@ 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 WOW_WMO_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_wmo_material.gd") +const WMO_BUILDER_FORMAT_VERSION := 2 +const WMO_BUILDER_FORMAT_META := "openwc_wmo_builder_format_version" const BUILD_OCCLUDERS := false const OCCLUDER_MIN_TRIANGLES := 16 @@ -29,11 +31,20 @@ static func clear_caches() -> void: _m2_missing_cache.clear() _texture_cache.clear() WOW_LIQUID_MATERIAL.clear_cache() + WOW_WMO_MATERIAL.clear_cache() + + +static func is_scene_cache_current(root: Node) -> bool: + if root == null: + return false + return int(root.get_meta(WMO_BUILDER_FORMAT_META, 0)) >= WMO_BUILDER_FORMAT_VERSION + # Returns a Node3D containing one MeshInstance3D per WMO group. static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: var root := Node3D.new() root.name = "WMO" + root.set_meta(WMO_BUILDER_FORMAT_META, WMO_BUILDER_FORMAT_VERSION) if not data.has("groups"): return root @@ -42,7 +53,7 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: var materials: Array = data.get("materials", []) # Build Godot materials from WMO material definitions - var godot_materials: Array[StandardMaterial3D] = [] + var godot_materials: Array[Material] = [] for mat_def in materials: godot_materials.append(_build_material(mat_def, textures, extracted_dir)) @@ -110,7 +121,10 @@ static func _build_default_doodads(root: Node3D, data: Dictionary, var pos: Vector3 = p.get("pos", Vector3.ZERO) var rot_q: Quaternion = p.get("rot", Quaternion.IDENTITY) var scl: float = float(p.get("scale", 1.0)) - var basis := (Basis(rot_q) * Basis(Vector3.UP, M2_RIGHT_YAW_OFFSET)).scaled(Vector3.ONE * maxf(scl, 0.001)) + # MODD is already a local WMO doodad transform. Do not apply the ADT M2 + # yaw correction here; it rotates anchored effects like waterfalls away + # from their authored cliff attachment points. + var basis := Basis(rot_q).scaled(Vector3.ONE * maxf(scl, 0.001)) var xform := Transform3D(basis, pos) if not groups.has(rel_path): groups[rel_path] = [] @@ -207,7 +221,7 @@ static func _build_raw_m2_prototype(rel_path: String, extracted_dir: String) -> static func _build_group_mesh( g: Dictionary, - godot_mats: Array[StandardMaterial3D]) -> MeshInstance3D: + godot_mats: Array[Material]) -> MeshInstance3D: var verts: PackedVector3Array = g.get("vertices", PackedVector3Array()) var normals: PackedVector3Array = g.get("normals", PackedVector3Array()) @@ -331,37 +345,54 @@ static func _material_can_occlude(mat_id: int, material_defs: Array) -> bool: static func _build_material( mat_def: Dictionary, textures: PackedStringArray, - extracted_dir: String = "") -> StandardMaterial3D: - - var mat := StandardMaterial3D.new() - mat.cull_mode = BaseMaterial3D.CULL_DISABLED - mat.roughness = 0.85 - mat.metallic = 0.0 - 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) + extracted_dir: String = "") -> Material: 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 + var flags: int = int(mat_def.get("flags", 0)) + var shader_id: int = int(mat_def.get("shader", 0)) + var tex: Texture2D = null + var tex2: Texture2D = null + var tex3: Texture2D = null + var tex_path := "" + var tex2_path := "" + var tex3_path := "" # Texture is loaded later by the scene assembler (needs BLP→Image conversion) # Store the path as metadata for the assembler to pick up var tex0_id: int = mat_def.get("texture0", -1) if tex0_id >= 0 and tex0_id < textures.size(): - var tex_path: String = str(textures[tex0_id]).replace("\\", "/") - mat.set_meta("texture0_path", tex_path) - 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, true) + tex_path = str(textures[tex0_id]).replace("\\", "/") + tex = _load_texture(tex_path, extracted_dir) + var tex1_id: int = mat_def.get("texture1", -1) + if tex1_id >= 0 and tex1_id < textures.size(): + tex2_path = str(textures[tex1_id]).replace("\\", "/") + tex2 = _load_texture(tex2_path, extracted_dir) + var tex2_id: int = mat_def.get("texture2", -1) + if tex2_id >= 0 and tex2_id < textures.size(): + tex3_path = str(textures[tex2_id]).replace("\\", "/") + tex3 = _load_texture(tex3_path, extracted_dir) - mat.set_meta("wow_flags", mat_def.get("flags", 0)) - mat.set_meta("wow_shader", mat_def.get("shader", 0)) + var diffuse_color: Color = mat_def.get("diffuse_color", Color.WHITE) + var emissive_color: Color = mat_def.get("emissive_color", Color.BLACK) + var secondary_color: Color = mat_def.get("color2", Color.WHITE) + var mat := WOW_WMO_MATERIAL.build( + tex, + tex2, + flags, + shader_id, + blend_mode, + tex_path, + tex2_path, + diffuse_color, + emissive_color, + secondary_color, + tex3, + tex3_path) + mat.set_meta("texture0_path", tex_path) + mat.set_meta("texture1_path", tex2_path) + mat.set_meta("texture2_path", tex3_path) + mat.set_meta("wow_flags", flags) + mat.set_meta("wow_shader", shader_id) return mat diff --git a/addons/mpq_extractor/loaders/wow_liquid_material.gd b/addons/mpq_extractor/loaders/wow_liquid_material.gd index c67e94c..8ae230e 100644 --- a/addons/mpq_extractor/loaders/wow_liquid_material.gd +++ b/addons/mpq_extractor/loaders/wow_liquid_material.gd @@ -9,6 +9,10 @@ static func clear_cache() -> void: _cache.clear() +static func _ensure_wow_shader_globals() -> void: + pass + + static func build(liquid_id: int) -> ShaderMaterial: var key := "adt:%d" % liquid_id if _cache.has(key): @@ -108,6 +112,7 @@ static func _get_shader() -> Shader: if _shader: return _shader + _ensure_wow_shader_globals() _shader = Shader.new() _shader.code = """ shader_type spatial; @@ -123,7 +128,12 @@ uniform float fresnel_power = 2.3; uniform float emission_strength = 0.025; uniform float magma_mode = 0.0; +global uniform vec4 wow_fog_color; +global uniform vec2 wow_fog_range; +global uniform float wow_fog_density; + varying vec3 world_pos; +varying vec3 view_pos; float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); @@ -140,8 +150,16 @@ float noise21(vec2 p) { return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } +vec3 apply_wow_fog(vec3 color) { + float dist = length(view_pos); + float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist); + float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 1.0); + return mix(color, wow_fog_color.rgb, fog_amount); +} + void vertex() { world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + view_pos = (MODELVIEW_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); @@ -170,7 +188,7 @@ void fragment() { EMISSION = color * emission_strength; } - ALBEDO = color; + ALBEDO = apply_wow_fog(color); ALPHA = clamp(alpha_base + fresnel * 0.18 + ripple * 0.06, 0.0, 1.0); } """ diff --git a/addons/mpq_extractor/loaders/wow_m2_material.gd b/addons/mpq_extractor/loaders/wow_m2_material.gd new file mode 100644 index 0000000..31597fe --- /dev/null +++ b/addons/mpq_extractor/loaders/wow_m2_material.gd @@ -0,0 +1,517 @@ +extends RefCounted +class_name WowM2Material + +const M2BLEND_OPAQUE := 0 +const M2BLEND_ALPHA_KEY := 1 +const M2BLEND_ALPHA := 2 +const M2BLEND_NO_ALPHA_ADD := 3 +const M2BLEND_ADD := 4 +const M2BLEND_MOD := 5 +const M2BLEND_MOD_2X := 6 + +static var _shader_cache: Dictionary = {} + + +static func clear_cache() -> void: + _shader_cache.clear() + + +static func _ensure_wow_shader_globals() -> void: + pass + + +static func build( + texture: Texture2D, + texture2: Texture2D, + texture3: Texture2D, + texture4: Texture2D, + material_flags: int, + blend_mode: int, + texture_flags: int, + texture2_flags: int, + texture3_flags: int, + texture4_flags: int, + resource_name: String = "", + combiner: Dictionary = {}) -> ShaderMaterial: + + var two_sided := (material_flags & 0x04) != 0 + var unlit := (material_flags & 0x01) != 0 or blend_mode >= M2BLEND_MOD + var depth_test := (material_flags & 0x08) == 0 + var depth_write := (material_flags & 0x10) == 0 + var repeat_enabled := ( + (texture_flags & 0x03) != 0 + or (texture2_flags & 0x03) != 0 + or (texture3_flags & 0x03) != 0 + or (texture4_flags & 0x03) != 0 + ) + var mode := _blend_shader_mode(blend_mode) + + _ensure_wow_shader_globals() + var mat := ShaderMaterial.new() + mat.resource_name = resource_name + mat.shader = _get_shader(mode, two_sided, depth_test, depth_write, repeat_enabled, texture2 != null, texture3 != null, texture4 != null) + mat.render_priority = clampi(_render_priority(blend_mode) + int(combiner.get("priority_plane", 0)), -128, 127) + mat.set_shader_parameter("albedo_tex", texture) + mat.set_shader_parameter("stage2_tex", texture2) + mat.set_shader_parameter("stage3_tex", texture3) + mat.set_shader_parameter("stage4_tex", texture4) + mat.set_shader_parameter("use_texture", texture != null) + mat.set_shader_parameter("use_stage2", texture2 != null) + mat.set_shader_parameter("use_stage3", texture3 != null) + mat.set_shader_parameter("use_stage4", texture4 != null) + mat.set_shader_parameter("alpha_ref", _alpha_ref(blend_mode)) + mat.set_shader_parameter("unlit_strength", 1.0 if unlit else 0.0) + mat.set_shader_parameter("mesh_color", combiner.get("mesh_color", Color.WHITE)) + mat.set_shader_parameter("tex_sample_alpha", combiner.get("tex_sample_alpha", Vector3.ONE)) + mat.set_shader_parameter("ambient_color", Color(0.45, 0.45, 0.42, 1.0)) + mat.set_shader_parameter("light_color", Color(0.82, 0.78, 0.68, 1.0)) + mat.set_shader_parameter("light_dir", Vector3(-0.45, -0.75, -0.35).normalized()) + mat.set_shader_parameter("diffuse_strength", 0.72) + mat.set_shader_parameter("modulate_2x", 1.0 if blend_mode == M2BLEND_MOD_2X else 0.0) + mat.set_shader_parameter("vertex_shader_id", int(combiner.get("vertex_shader_id", 0))) + mat.set_shader_parameter("pixel_shader_id", int(combiner.get("pixel_shader_id", 0))) + mat.set_shader_parameter("stage0_op", int(combiner.get("op0", 0 if blend_mode == 0 else 1))) + mat.set_shader_parameter("stage1_op", int(combiner.get("op1", 1))) + mat.set_shader_parameter("stage0_env", bool(combiner.get("env0", false))) + mat.set_shader_parameter("stage1_env", bool(combiner.get("env1", false))) + mat.set_shader_parameter("stage0_uv2", bool(combiner.get("use_uv2_0", false))) + mat.set_shader_parameter("stage1_uv2", bool(combiner.get("use_uv2_1", false))) + mat.set_shader_parameter("stage0_uv_transform", combiner.get("stage0_uv_transform", Vector4(1.0, 1.0, 0.0, 0.0))) + mat.set_shader_parameter("stage1_uv_transform", combiner.get("stage1_uv_transform", Vector4(1.0, 1.0, 0.0, 0.0))) + mat.set_shader_parameter("stage2_uv_transform", combiner.get("stage2_uv_transform", Vector4(1.0, 1.0, 0.0, 0.0))) + mat.set_shader_parameter("stage3_uv_transform", combiner.get("stage3_uv_transform", Vector4(1.0, 1.0, 0.0, 0.0))) + mat.set_shader_parameter("stage0_uv_scroll", combiner.get("stage0_uv_scroll", Vector2.ZERO)) + mat.set_shader_parameter("stage1_uv_scroll", combiner.get("stage1_uv_scroll", Vector2.ZERO)) + mat.set_shader_parameter("stage2_uv_scroll", combiner.get("stage2_uv_scroll", Vector2.ZERO)) + mat.set_shader_parameter("stage3_uv_scroll", combiner.get("stage3_uv_scroll", Vector2.ZERO)) + mat.set_shader_parameter("effect_alpha_scale", combiner.get("effect_alpha_scale", 1.0)) + mat.set_shader_parameter("effect_alpha_power", combiner.get("effect_alpha_power", 1.0)) + mat.set_shader_parameter("uv_edge_fade_strength", combiner.get("uv_edge_fade_strength", 0.0)) + mat.set_meta("wow_flags", material_flags) + mat.set_meta("wow_blend_mode", blend_mode) + mat.set_meta("wow_priority_plane", int(combiner.get("priority_plane", 0))) + mat.set_meta("wow_texture_flags", texture_flags) + mat.set_meta("wow_texture2_flags", texture2_flags) + mat.set_meta("wow_texture3_flags", texture3_flags) + mat.set_meta("wow_texture4_flags", texture4_flags) + return mat + + +static func _blend_shader_mode(blend_mode: int) -> String: + match blend_mode: + M2BLEND_ALPHA_KEY: + return "cutout" + M2BLEND_ALPHA: + return "alpha" + M2BLEND_NO_ALPHA_ADD, M2BLEND_ADD: + return "add" + M2BLEND_MOD, M2BLEND_MOD_2X: + return "mul" + _: + return "opaque" + + +static func _render_priority(blend_mode: int) -> int: + if blend_mode <= M2BLEND_ALPHA_KEY: + return 0 + if blend_mode >= M2BLEND_MOD: + return 2 + return 1 + + +static func _alpha_ref(blend_mode: int) -> float: + if blend_mode == M2BLEND_ALPHA_KEY: + return 0.87843138 + if blend_mode == M2BLEND_OPAQUE: + return 0.0 + return 0.0039215689 + + +static func _get_shader( + mode: String, + two_sided: bool, + depth_test: bool, + depth_write: bool, + repeat_enabled: bool, + has_stage2: bool, + has_stage3: bool, + has_stage4: bool) -> Shader: + + var key := "%s|sided=%s|dt=%s|dw=%s|repeat=%s" % [ + mode, + str(two_sided), + str(depth_test), + str(depth_write), + str(repeat_enabled), + ] + key += "|stage2=%s" % str(has_stage2) + key += "|stage3=%s|stage4=%s" % [str(has_stage3), str(has_stage4)] + if _shader_cache.has(key): + return _shader_cache[key] + + var shader := Shader.new() + shader.code = _shader_code(mode, two_sided, depth_test, depth_write, repeat_enabled, has_stage2, has_stage3, has_stage4) + _shader_cache[key] = shader + return shader + + +static func _shader_code( + mode: String, + two_sided: bool, + depth_test: bool, + depth_write: bool, + repeat_enabled: bool, + has_stage2: bool, + has_stage3: bool, + has_stage4: bool) -> String: + + var render_modes: Array[String] = [] + match mode: + "alpha": + render_modes.append("blend_mix") + "add": + render_modes.append("blend_add") + "mul": + render_modes.append("blend_mul") + _: + render_modes.append("blend_mix") + + render_modes.append("cull_disabled" if two_sided else "cull_back") + if not depth_test: + render_modes.append("depth_test_disabled") + if not depth_write: + render_modes.append("depth_draw_never") + elif mode == "opaque" or mode == "cutout": + render_modes.append("depth_draw_opaque") + else: + render_modes.append("depth_draw_never") + render_modes.append("unshaded") + + var repeat_hint := "repeat_enable" if repeat_enabled else "repeat_disable" + return """ +shader_type spatial; +render_mode %s; + +uniform sampler2D albedo_tex : source_color, filter_linear_mipmap_anisotropic, %s; +uniform sampler2D stage2_tex : source_color, filter_linear_mipmap_anisotropic, %s; +uniform sampler2D stage3_tex : source_color, filter_linear_mipmap_anisotropic, %s; +uniform sampler2D stage4_tex : source_color, filter_linear_mipmap_anisotropic, %s; +uniform bool use_texture = false; +uniform bool use_stage2 = false; +uniform bool use_stage3 = false; +uniform bool use_stage4 = false; +uniform float alpha_ref = 0.0; +uniform float unlit_strength = 0.0; +uniform vec4 mesh_color : source_color = vec4(1.0); +uniform vec3 tex_sample_alpha = vec3(1.0); +uniform vec4 ambient_color : source_color = vec4(0.45, 0.45, 0.42, 1.0); +uniform vec4 light_color : source_color = vec4(0.82, 0.78, 0.68, 1.0); +uniform vec3 light_dir = vec3(-0.45, -0.75, -0.35); +uniform float diffuse_strength = 0.72; +uniform float modulate_2x = 0.0; +uniform int vertex_shader_id = 0; +uniform int pixel_shader_id = 0; +uniform int stage0_op = 0; +uniform int stage1_op = 1; +uniform bool stage0_env = false; +uniform bool stage1_env = false; +uniform bool stage0_uv2 = false; +uniform bool stage1_uv2 = false; +uniform vec4 stage0_uv_transform = vec4(1.0, 1.0, 0.0, 0.0); +uniform vec4 stage1_uv_transform = vec4(1.0, 1.0, 0.0, 0.0); +uniform vec4 stage2_uv_transform = vec4(1.0, 1.0, 0.0, 0.0); +uniform vec4 stage3_uv_transform = vec4(1.0, 1.0, 0.0, 0.0); +uniform vec2 stage0_uv_scroll = vec2(0.0); +uniform vec2 stage1_uv_scroll = vec2(0.0); +uniform vec2 stage2_uv_scroll = vec2(0.0); +uniform vec2 stage3_uv_scroll = vec2(0.0); +uniform float effect_alpha_scale = 1.0; +uniform float effect_alpha_power = 1.0; +uniform float uv_edge_fade_strength = 0.0; + +global uniform vec4 wow_ambient_color; +global uniform vec4 wow_light_color; +global uniform vec3 wow_light_dir; +global uniform vec4 wow_fog_color; +global uniform vec2 wow_fog_range; +global uniform float wow_fog_density; +global uniform float wow_sun_elevation; + +varying vec3 world_normal; +varying vec3 world_pos; +varying vec3 view_pos; +varying vec2 uv_interp; +varying vec2 uv2_interp; +varying float edge_fade; + +void vertex() { + world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL); + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; + uv_interp = UV; + uv2_interp = UV2; + vec3 view_normal = normalize((MODELVIEW_MATRIX * vec4(NORMAL, 0.0)).xyz); + float edge_scan = 1.0 - abs(dot(normalize(-view_pos), view_normal)); + edge_fade = (vertex_shader_id == 9 || vertex_shader_id == 12 || vertex_shader_id == 13) ? clamp(edge_scan * 2.5, 0.0, 1.0) : 1.0; +} + +vec3 apply_wow_fog(vec3 color) { + float dist = length(view_pos); + float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist); + float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55); + return mix(color, wow_fog_color.rgb, fog_amount); +} + +vec2 transform_uv(vec2 uv, vec4 uv_transform, vec2 uv_scroll) { + return uv * uv_transform.xy + uv_transform.zw + uv_scroll * TIME; +} + +vec2 env_uv(vec3 normal) { + vec3 n = normalize(normal); + return n.xy * 0.5 + vec2(0.5); +} + +vec2 stage_uv(int stage, vec3 normal) { + vec2 t1 = transform_uv(uv_interp, stage0_uv_transform, stage0_uv_scroll); + vec2 t2 = transform_uv(uv2_interp, stage1_uv_transform, stage1_uv_scroll); + vec2 t3 = transform_uv(uv2_interp, stage2_uv_transform, stage2_uv_scroll); + vec2 t4 = transform_uv(uv2_interp, stage3_uv_transform, stage3_uv_scroll); + vec2 env = env_uv(normal); + + if (vertex_shader_id == 1) { + return env; + } + if (vertex_shader_id == 2) { + return stage == 0 ? t1 : t2; + } + if (vertex_shader_id == 3) { + return stage == 0 ? t1 : env; + } + if (vertex_shader_id == 4) { + return stage == 0 ? env : t1; + } + if (vertex_shader_id == 5) { + return env; + } + if (vertex_shader_id == 6) { + return stage == 1 ? env : t1; + } + if (vertex_shader_id == 7 || vertex_shader_id == 8) { + return t1; + } + if (vertex_shader_id == 10) { + return t2; + } + if (vertex_shader_id == 11) { + return stage == 1 ? env : (stage == 2 ? t2 : t1); + } + if (vertex_shader_id == 12) { + return stage == 0 ? t1 : t2; + } + if (vertex_shader_id == 13) { + return env; + } + if (vertex_shader_id == 14) { + return stage == 1 ? t2 : t1; + } + if (vertex_shader_id == 15) { + return stage == 0 ? t1 : (stage == 1 ? t2 : uv2_interp); + } + if (vertex_shader_id == 16) { + return stage == 0 ? t2 : uv2_interp; + } + if (stage == 2) { + return t3; + } + if (stage == 3) { + return t4; + } + if (stage == 0) { + if (stage0_env) { + return env; + } + return transform_uv(stage0_uv2 ? uv2_interp : uv_interp, stage0_uv_transform, stage0_uv_scroll); + } + if (stage1_env) { + return env; + } + return transform_uv(stage1_uv2 ? uv2_interp : uv_interp, stage1_uv_transform, stage1_uv_scroll); +} + +void resolve_m2_pixel(vec4 tex1, vec4 tex2, vec4 tex3, vec4 tex4, out vec3 diffuse, out vec3 specular, out float discard_alpha, out float can_discard) { + vec3 mesh_rgb = mesh_color.rgb; + diffuse = mesh_rgb * tex1.rgb; + specular = vec3(0.0); + discard_alpha = tex1.a * mesh_color.a; + can_discard = 0.0; + + if (pixel_shader_id == 0) { + diffuse = mesh_rgb * tex1.rgb; + } else if (pixel_shader_id == 1) { + diffuse = mesh_rgb * tex1.rgb; + discard_alpha = tex1.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 2) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb; + discard_alpha = tex2.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 3) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb * 2.0; + discard_alpha = tex2.a * 2.0 * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 4) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb * 2.0; + } else if (pixel_shader_id == 5) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb; + } else if (pixel_shader_id == 6) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb; + discard_alpha = tex1.a * tex2.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 7) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb * 2.0; + discard_alpha = tex1.a * tex2.a * 2.0 * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 8) { + diffuse = mesh_rgb * tex1.rgb; + specular = tex2.rgb; + discard_alpha = (tex1.a + tex2.a) * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 9) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb * 2.0; + discard_alpha = tex1.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 10) { + diffuse = mesh_rgb * tex1.rgb; + specular = tex2.rgb; + discard_alpha = tex1.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 11) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb; + discard_alpha = tex1.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 12) { + diffuse = mesh_rgb * mix(tex1.rgb * tex2.rgb * 2.0, tex1.rgb, vec3(tex1.a)); + } else if (pixel_shader_id == 13) { + diffuse = mesh_rgb * tex1.rgb; + specular = tex2.rgb * tex2.a; + } else if (pixel_shader_id == 14) { + diffuse = mesh_rgb * tex1.rgb; + specular = tex2.rgb * tex2.a * (1.0 - tex1.a); + } else if (pixel_shader_id == 15) { + diffuse = mesh_rgb * mix(tex1.rgb * tex2.rgb * 2.0, tex1.rgb, vec3(tex1.a)); + specular = tex3.rgb * tex3.a * tex_sample_alpha.b; + } else if (pixel_shader_id == 16) { + diffuse = mesh_rgb * tex1.rgb; + specular = tex2.rgb * tex2.a; + discard_alpha = tex1.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 17) { + diffuse = mesh_rgb * tex1.rgb; + specular = tex2.rgb * tex2.a * (1.0 - tex1.a); + discard_alpha = (tex1.a + tex2.a * dot(tex2.rgb, vec3(0.3, 0.59, 0.11))) * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 18) { + diffuse = mesh_rgb * mix(mix(tex1.rgb, tex2.rgb, vec3(tex2.a)), tex1.rgb, vec3(tex1.a)); + } else if (pixel_shader_id == 19) { + diffuse = mesh_rgb * mix(tex1.rgb * tex2.rgb * 2.0, tex3.rgb, vec3(tex3.a)); + } else if (pixel_shader_id == 21) { + diffuse = mesh_rgb * tex1.rgb; + specular = tex2.rgb * (1.0 - tex1.a); + discard_alpha = (tex1.a + tex2.a) * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 22) { + diffuse = mesh_rgb * mix(tex1.rgb * tex2.rgb, tex1.rgb, vec3(tex1.a)); + } else if (pixel_shader_id == 24) { + diffuse = mesh_rgb * mix(tex1.rgb, tex2.rgb, vec3(tex2.a)); + specular = tex1.rgb * tex1.a * tex_sample_alpha.r; + } else if (pixel_shader_id == 25) { + float glow_opacity = clamp(tex3.a * tex_sample_alpha.b, 0.0, 1.0); + diffuse = mesh_rgb * mix(tex1.rgb * tex2.rgb * 2.0, tex1.rgb, vec3(tex1.a)) * (1.0 - glow_opacity); + specular = tex3.rgb * glow_opacity; + } else if (pixel_shader_id == 26) { + vec4 mixed = mix(mix(tex1, tex2, vec4(clamp(tex_sample_alpha.g, 0.0, 1.0))), tex3, vec4(clamp(tex_sample_alpha.b, 0.0, 1.0))); + diffuse = mesh_rgb * mixed.rgb; + discard_alpha = mixed.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 27) { + diffuse = mesh_rgb * mix(mix(tex1.rgb * tex2.rgb * 2.0, tex3.rgb, vec3(tex3.a)), tex1.rgb, vec3(tex1.a)); + } else if (pixel_shader_id == 28) { + vec4 mixed = mix(mix(tex1, tex2, vec4(clamp(tex_sample_alpha.g, 0.0, 1.0))), tex3, vec4(clamp(tex_sample_alpha.b, 0.0, 1.0))); + diffuse = mesh_rgb * mixed.rgb; + discard_alpha = mixed.a * tex4.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 29) { + diffuse = mesh_rgb * mix(tex1.rgb, tex2.rgb, vec3(tex2.a)); + } else if (pixel_shader_id == 30) { + diffuse = mesh_rgb * mix(tex1.rgb * tex2.rgb, tex3.rgb, vec3(tex3.a)); + discard_alpha = tex1.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 31) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb; + discard_alpha = tex1.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 32) { + diffuse = mesh_rgb * mix(tex1.rgb * tex2.rgb, tex3.rgb, vec3(tex3.a)); + } else if (pixel_shader_id == 35) { + vec4 combined = tex1 * tex2 * tex3; + diffuse = mesh_rgb * combined.rgb; + discard_alpha = combined.a * mesh_color.a; + can_discard = 1.0; + } else if (pixel_shader_id == 36) { + diffuse = mesh_rgb * tex1.rgb * tex2.rgb; + discard_alpha = tex1.a * tex2.a * mesh_color.a; + can_discard = 1.0; + } +} + +void fragment() { + vec4 tex = use_texture ? texture(albedo_tex, stage_uv(0, world_normal)) : vec4(1.0); +%s + vec4 stage2 = use_stage2 ? texture(stage2_tex, stage_uv(1, world_normal)) : vec4(1.0); + vec4 stage3 = use_stage3 ? texture(stage3_tex, stage_uv(2, world_normal)) : vec4(1.0); + vec4 stage4 = use_stage4 ? texture(stage4_tex, stage_uv(3, world_normal)) : vec4(1.0); + vec3 mat_diffuse; + vec3 specular; + float discard_alpha; + float can_discard; + resolve_m2_pixel(tex, stage2, stage3, stage4, mat_diffuse, specular, discard_alpha, can_discard); + discard_alpha *= edge_fade; + discard_alpha = pow(clamp(discard_alpha, 0.0, 1.0), max(effect_alpha_power, 0.01)) * effect_alpha_scale; + if (uv_edge_fade_strength > 0.0) { + float edge_width = 0.12; + float edge_mask = smoothstep(0.0, edge_width, uv_interp.x) * smoothstep(0.0, edge_width, 1.0 - uv_interp.x); + edge_mask *= smoothstep(0.0, edge_width, uv_interp.y) * smoothstep(0.0, edge_width, 1.0 - uv_interp.y); + discard_alpha *= mix(1.0, edge_mask, clamp(uv_edge_fade_strength, 0.0, 1.0)); + } + if (alpha_ref > 0.0 && discard_alpha < alpha_ref) { + discard; + } + + vec3 n = normalize(world_normal); + if (!FRONT_FACING) { + n = -n; + } + float ndl = max(dot(n, normalize(wow_light_dir)), 0.0); + vec3 zone_ambient = mix(vec3(0.76), max(wow_ambient_color.rgb, vec3(0.08)), 0.18); + vec3 zone_light = mix(vec3(1.0), wow_light_color.rgb, 0.14); + float day_strength = mix(0.48, 0.92, clamp(wow_sun_elevation, 0.0, 1.0)); + vec3 lit = zone_ambient * 0.82 + zone_light * ndl * diffuse_strength * 0.72 * day_strength; + lit = clamp(lit, vec3(0.56), vec3(1.08)); + lit = mix(lit, vec3(1.0), clamp(unlit_strength, 0.0, 1.0)); + vec3 color = mat_diffuse * lit + specular * 0.45; + color = mix(color, min(color * 2.0, vec3(1.0)), clamp(modulate_2x, 0.0, 1.0)); + color = clamp(color, vec3(0.0), vec3(1.0)); + color = apply_wow_fog(color); + + ALBEDO = color; +%s +} +""" % [ + ", ".join(render_modes), + repeat_hint, + repeat_hint, + repeat_hint, + repeat_hint, + "", + "" if mode == "opaque" or mode == "cutout" else "\tALPHA = clamp(discard_alpha, 0.0, 1.0);", + ] diff --git a/addons/mpq_extractor/loaders/wow_m2_material.gd.uid b/addons/mpq_extractor/loaders/wow_m2_material.gd.uid new file mode 100644 index 0000000..ddd4715 --- /dev/null +++ b/addons/mpq_extractor/loaders/wow_m2_material.gd.uid @@ -0,0 +1 @@ +uid://dbyyg7wwr0y3w diff --git a/addons/mpq_extractor/loaders/wow_wmo_material.gd b/addons/mpq_extractor/loaders/wow_wmo_material.gd new file mode 100644 index 0000000..aa60f61 --- /dev/null +++ b/addons/mpq_extractor/loaders/wow_wmo_material.gd @@ -0,0 +1,336 @@ +extends RefCounted +class_name WowWMOMaterial + +const WMOBLEND_OPAQUE := 0 +const WMOBLEND_ALPHA_KEY := 1 +const WMOBLEND_ALPHA := 2 +const SHADER_VERSION := 3 + +static var _shader_cache: Dictionary = {} + + +static func clear_cache() -> void: + _shader_cache.clear() + + +static func _ensure_wow_shader_globals() -> void: + pass + + +static func build( + texture: Texture2D, + texture2: Texture2D, + material_flags: int, + shader_id: int, + blend_mode: int, + texture_path: String = "", + texture2_path: String = "", + diffuse_color: Color = Color.WHITE, + emissive_color: Color = Color.BLACK, + secondary_color: Color = Color.WHITE, + texture3: Texture2D = null, + texture3_path: String = "") -> ShaderMaterial: + + var mode := _blend_shader_mode(blend_mode) + var effective_diffuse := diffuse_color + if maxf(maxf(effective_diffuse.r, effective_diffuse.g), effective_diffuse.b) <= 0.001: + effective_diffuse = Color.WHITE + + _ensure_wow_shader_globals() + var mat := ShaderMaterial.new() + mat.resource_name = texture_path.get_file().get_basename() + var pixel_shader := _wmo_pixel_shader_id(shader_id) + mat.shader = _get_shader(mode, texture2 != null, texture3 != null) + mat.render_priority = 0 if blend_mode <= WMOBLEND_ALPHA_KEY else 1 + mat.set_shader_parameter("albedo_tex", texture) + mat.set_shader_parameter("detail_tex", texture2) + mat.set_shader_parameter("extra_tex", texture3) + mat.set_shader_parameter("use_texture", texture != null) + mat.set_shader_parameter("use_detail_texture", texture2 != null) + mat.set_shader_parameter("use_extra_texture", texture3 != null) + mat.set_shader_parameter("pixel_shader", pixel_shader) + mat.set_shader_parameter("alpha_ref", _alpha_ref(blend_mode, pixel_shader)) + mat.set_shader_parameter("alpha_output_strength", _alpha_output_strength(blend_mode, pixel_shader)) + mat.set_shader_parameter("vertex_color_strength", 0.72) + mat.set_shader_parameter("ambient_floor", 0.22) + mat.set_shader_parameter("diffuse_color", effective_diffuse) + mat.set_shader_parameter("emissive_color", emissive_color) + mat.set_shader_parameter("material_emissive_strength", _material_emissive_strength(material_flags, emissive_color, texture_path)) + mat.set_shader_parameter("secondary_color", _effective_secondary_color(secondary_color)) + mat.set_shader_parameter("detail_strength", 1.0 if texture2 != null else 0.0) + mat.set_meta("texture0_path", texture_path) + mat.set_meta("texture1_path", texture2_path) + mat.set_meta("texture2_path", texture3_path) + mat.set_meta("wow_flags", material_flags) + mat.set_meta("wow_shader", shader_id) + mat.set_meta("wow_wmo_pixel_shader", pixel_shader) + mat.set_meta("wow_blend_mode", blend_mode) + return mat + + +static func _wmo_pixel_shader_id(shader_id: int) -> int: + match shader_id: + 0: + return 0 # Diffuse + 1: + return 1 # Specular + 2: + return 2 # Metal + 3: + return 3 # Env + 4: + return 4 # Opaque + 5: + return 5 # EnvMetal + 6: + return 6 # TwoLayerDiffuse + 7: + return 7 # TwoLayerEnvMetal + 8: + return 8 # TwoLayerTerrain + 9: + return 9 # DiffuseEmissive + 11: + return 10 # MaskedEnvMetal + 12: + return 11 # EnvMetalEmissive + 13: + return 12 # TwoLayerDiffuseOpaque + 15: + return 13 # TwoLayerDiffuseEmissive + 16: + return 0 # DiffuseTerrain + 17: + return 14 # AdditiveMaskedEnvMetal + 18: + return 15 # TwoLayerDiffuseMod2x + 19: + return 16 # TwoLayerDiffuseMod2xNA + 20: + return 17 # TwoLayerDiffuseAlpha + 21: + return 18 # Lod + 22: + return 19 # Parallax + 23: + return 20 # DF shader + _: + return 0 + + +static func _shader_alpha_is_opacity(pixel_shader: int) -> bool: + match pixel_shader: + 0, 1, 2, 4, 18, 19: + return true + _: + return false + + +static func _alpha_ref(blend_mode: int, pixel_shader: int) -> float: + if blend_mode > WMOBLEND_OPAQUE: + return 0.501960814 + return 0.0 + + +static func _alpha_output_strength(blend_mode: int, pixel_shader: int) -> float: + if blend_mode == WMOBLEND_ALPHA and _shader_alpha_is_opacity(pixel_shader): + return 1.0 + return 0.0 + + +static func _effective_secondary_color(color: Color) -> Color: + if maxf(maxf(color.r, color.g), color.b) <= 0.001: + return Color.WHITE + return color + + +static func _material_emissive_strength(material_flags: int, color: Color, texture_path: String) -> float: + var color_strength := maxf(maxf(color.r, color.g), color.b) * color.a + if color_strength <= 0.001: + return 0.0 + var strength := 0.12 + if (material_flags & 0x10) != 0: + strength = 0.26 + var lower_path := texture_path.to_lower() + if lower_path.contains("window") or lower_path.contains("_wnd_") or lower_path.contains("wnd_"): + strength = minf(strength, 0.18) + return strength + + +static func _blend_shader_mode(blend_mode: int) -> String: + match blend_mode: + WMOBLEND_ALPHA_KEY: + return "cutout" + WMOBLEND_ALPHA: + return "alpha" + _: + return "opaque" + + +static func _get_shader(mode: String, has_detail: bool, has_extra: bool) -> Shader: + var key := "v=%d|%s|detail=%s|extra=%s" % [SHADER_VERSION, mode, str(has_detail), str(has_extra)] + if _shader_cache.has(key): + return _shader_cache[key] + + var shader := Shader.new() + shader.code = _shader_code(mode, has_detail, has_extra) + _shader_cache[key] = shader + return shader + + +static func _shader_code(mode: String, has_detail: bool, has_extra: bool) -> String: + var render_modes: Array[String] = ["blend_mix", "cull_disabled", "unshaded"] + if mode == "opaque" or mode == "cutout": + render_modes.append("depth_draw_opaque") + else: + render_modes.append("depth_prepass_alpha") + + return """ +shader_type spatial; +render_mode %s; + +uniform sampler2D albedo_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; +uniform sampler2D detail_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; +uniform sampler2D extra_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; +uniform bool use_texture = false; +uniform bool use_detail_texture = false; +uniform bool use_extra_texture = false; +uniform int pixel_shader = 0; +uniform float alpha_ref = 0.0; +uniform float alpha_output_strength = 0.0; +uniform float vertex_color_strength = 0.88; +uniform float ambient_floor = 0.20; +uniform vec4 diffuse_color : source_color = vec4(1.0); +uniform vec4 emissive_color : source_color = vec4(0.0); +uniform float material_emissive_strength = 0.0; +uniform vec4 secondary_color : source_color = vec4(1.0); +uniform float detail_strength = 0.0; + +global uniform vec4 wow_ambient_color; +global uniform vec4 wow_light_color; +global uniform vec3 wow_light_dir; +global uniform vec4 wow_fog_color; +global uniform vec2 wow_fog_range; +global uniform float wow_fog_density; +global uniform float wow_sun_elevation; + +varying vec3 world_normal; +varying vec3 world_pos; +varying vec3 view_pos; + +void vertex() { + world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL); + world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +vec3 apply_wow_fog(vec3 color) { + float dist = length(view_pos); + float range_fog = smoothstep(wow_fog_range.x, wow_fog_range.y, dist); + float fog_amount = range_fog * clamp(wow_fog_density, 0.0, 0.55); + return mix(color, wow_fog_color.rgb, fog_amount); +} + +float color_luma(vec3 color) { + return dot(color, vec3(0.2126, 0.7152, 0.0722)); +} + +vec3 textured_material_tint(vec3 color) { + float luma = max(color_luma(color), 0.001); + vec3 hue = clamp(color / luma, vec3(0.58), vec3(1.20)); + return mix(vec3(1.0), hue, 0.28); +} + +vec3 untextured_material_color(vec3 color) { + float luma = color_luma(color); + vec3 muted = mix(vec3(luma), color, 0.42); + return clamp(muted * 0.82, vec3(0.08), vec3(0.72)); +} + +void resolve_wmo_pixel(vec4 tex1, vec4 tex2, vec4 tex3, bool has_detail, bool has_extra, out vec3 diffuse, out vec3 emissive) { + float layer_weight = clamp(secondary_color.a, 0.0, 1.0); + diffuse = tex1.rgb; + emissive = vec3(0.0); + if (!has_detail) { + return; + } + + if (pixel_shader == 3) { + emissive = tex2.rgb * tex1.a; + } else if (pixel_shader == 5) { + emissive = (tex1.rgb * tex1.a) * tex2.rgb; + } else if (pixel_shader == 6) { + vec3 layer2 = mix(tex1.rgb, tex2.rgb, tex2.a); + diffuse = mix(layer2, tex1.rgb, layer_weight); + } else if (pixel_shader == 8 || pixel_shader == 12) { + diffuse = mix(tex2.rgb, tex1.rgb, layer_weight); + } else if (pixel_shader == 9) { + emissive = tex2.rgb * tex2.a * layer_weight; + } else if (pixel_shader == 10 && has_extra) { + float mix_factor = clamp(tex3.a * layer_weight, 0.0, 1.0); + diffuse = mix(mix(tex1.rgb * tex2.rgb * 2.0, tex3.rgb, mix_factor), tex1.rgb, tex1.a); + } else if (pixel_shader == 11 && has_extra) { + emissive = ((tex1.rgb * tex1.a) * tex2.rgb) + ((tex3.rgb * tex3.a) * layer_weight); + } else if (pixel_shader == 13) { + vec3 layer = tex2.rgb * (1.0 - tex2.a); + diffuse = mix(layer, tex1.rgb, layer_weight); + emissive = (tex2.rgb * tex2.a) * (1.0 - layer_weight); + } else if (pixel_shader == 14 && has_extra) { + diffuse = mix( + (tex1.rgb * tex2.rgb * 2.0) + (tex3.rgb * clamp(tex3.a * layer_weight, 0.0, 1.0)), + tex1.rgb, + tex1.a + ); + } else if (pixel_shader == 15) { + vec3 layer2 = mix(tex1.rgb, tex2.rgb, tex2.a); + vec3 layer3 = mix(layer2, tex1.rgb, layer_weight); + diffuse = has_extra ? layer3 * tex3.rgb * 2.0 : layer3; + } else if (pixel_shader == 16) { + vec3 mod_layer = tex1.rgb * tex2.rgb * 2.0; + diffuse = mix(tex1.rgb, mod_layer, layer_weight); + } else if (pixel_shader == 17) { + vec3 layer2 = mix(tex1.rgb, tex2.rgb, tex2.a); + vec3 layer3 = mix(layer2, tex1.rgb, has_extra ? tex3.a : layer_weight); + diffuse = has_extra ? layer3 * tex3.rgb * 2.0 : layer3; + } +} + +void fragment() { + vec4 tex1 = use_texture ? texture(albedo_tex, UV) : vec4(1.0); + vec4 tex2 = use_detail_texture ? texture(detail_tex, UV) : vec4(1.0); + vec4 tex3 = use_extra_texture ? texture(extra_tex, UV) : vec4(1.0); + if (alpha_ref > 0.0 && tex1.a < alpha_ref) { + discard; + } + + vec3 mat_diffuse; + vec3 shader_emissive; + resolve_wmo_pixel(tex1, tex2, tex3, use_detail_texture, use_extra_texture, mat_diffuse, shader_emissive); + + vec3 baked = max(COLOR.rgb, vec3(ambient_floor)); + vec3 baked_lit = mix(vec3(1.0), baked, clamp(vertex_color_strength, 0.0, 1.0)); + vec3 n = normalize(world_normal); + if (!FRONT_FACING) { + n = -n; + } + float ndl = max(dot(n, normalize(wow_light_dir)), 0.0); + vec3 zone_ambient = mix(vec3(0.78), max(wow_ambient_color.rgb, vec3(0.08)), 0.16); + vec3 zone_light = mix(vec3(1.0), wow_light_color.rgb, 0.10); + float day_strength = mix(0.48, 0.92, clamp(wow_sun_elevation, 0.0, 1.0)); + vec3 lit = baked_lit * (zone_ambient * 0.86 + zone_light * ndl * 0.24 * day_strength); + lit = clamp(lit, vec3(0.46), vec3(1.05)); + vec3 material_color = untextured_material_color(diffuse_color.rgb); + if (use_texture) { + material_color = mat_diffuse * textured_material_tint(diffuse_color.rgb); + } + float emissive_mask = smoothstep(0.35, 0.92, color_luma(mat_diffuse)); + vec3 material_emissive = mat_diffuse * emissive_color.rgb * emissive_color.a * material_emissive_strength * emissive_mask; + vec3 emissive = shader_emissive * 0.35 + material_emissive; + vec3 color = clamp(material_color * lit + emissive, vec3(0.0), vec3(1.0)); + ALBEDO = apply_wow_fog(color); +%s +} +""" % [ + ", ".join(render_modes), + "" if mode == "opaque" or mode == "cutout" else "\tALPHA = mix(diffuse_color.a, tex1.a * diffuse_color.a, clamp(alpha_output_strength, 0.0, 1.0));", + ] diff --git a/addons/mpq_extractor/loaders/wow_wmo_material.gd.uid b/addons/mpq_extractor/loaders/wow_wmo_material.gd.uid new file mode 100644 index 0000000..c51c391 --- /dev/null +++ b/addons/mpq_extractor/loaders/wow_wmo_material.gd.uid @@ -0,0 +1 @@ +uid://c1ju24mvhoi5e diff --git a/project.godot b/project.godot index 6eb634e..4b44231 100644 --- a/project.godot +++ b/project.godot @@ -27,3 +27,34 @@ enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg") rendering_device/driver.windows="d3d12" rendering/rendering_device/d3d12/max_resource_descriptors=1048576 + +[shader_globals] + +wow_ambient_color={ +"type": "color", +"value": Color(0.72, 0.8, 0.88, 1) +} +wow_light_color={ +"type": "color", +"value": Color(1, 0.91, 0.78, 1) +} +wow_light_dir={ +"type": "vec3", +"value": Vector3(-0.35, 0.82, -0.45) +} +wow_fog_color={ +"type": "color", +"value": Color(0.55, 0.66, 0.72, 1) +} +wow_fog_range={ +"type": "vec2", +"value": Vector2(1200, 5200) +} +wow_fog_density={ +"type": "float", +"value": 0.0 +} +wow_sun_elevation={ +"type": "float", +"value": 1.0 +} diff --git a/reference/WoWee b/reference/WoWee new file mode 160000 index 0000000..626243e --- /dev/null +++ b/reference/WoWee @@ -0,0 +1 @@ +Subproject commit 626243e937fb93965fa583a6507ed5a1aa7dda4b diff --git a/reference/WowUnreal b/reference/WowUnreal new file mode 160000 index 0000000..c2a4b98 --- /dev/null +++ b/reference/WowUnreal @@ -0,0 +1 @@ +Subproject commit c2a4b9827b2e7672e799085c5bf631d37c4ca5d3 diff --git a/reference/whoa b/reference/whoa new file mode 160000 index 0000000..74bc963 --- /dev/null +++ b/reference/whoa @@ -0,0 +1 @@ +Subproject commit 74bc963a1c3c586a8960e6be937ca5713746bf77 diff --git a/src/native/src/m2_loader.cpp b/src/native/src/m2_loader.cpp index 8d325ba..39d1525 100644 --- a/src/native/src/m2_loader.cpp +++ b/src/native/src/m2_loader.cpp @@ -6,10 +6,17 @@ #include #include #include +#include +#include +#include #include #include #include +#include +#include +#include +#include using namespace godot; @@ -54,6 +61,38 @@ struct M2Header { uint32_t ofsBoneCombos; uint32_t nTextureCombos; // offset 128 uint32_t ofsTextureCombos; // offset 132 + uint32_t nTextureCoordCombos; + uint32_t ofsTextureCoordCombos; + uint32_t nTextureWeightCombos; + uint32_t ofsTextureWeightCombos; + uint32_t nTextureTransformCombos; + uint32_t ofsTextureTransformCombos; + float bounds[7]; + float collisionBounds[7]; + uint32_t nCollisionIndices; + uint32_t ofsCollisionIndices; + uint32_t nCollisionPositions; + uint32_t ofsCollisionPositions; + uint32_t nCollisionFaceNormals; + uint32_t ofsCollisionFaceNormals; + uint32_t nAttachments; + uint32_t ofsAttachments; + uint32_t nAttachmentLookup; + uint32_t ofsAttachmentLookup; + uint32_t nEvents; + uint32_t ofsEvents; + uint32_t nLights; + uint32_t ofsLights; + uint32_t nCameras; + uint32_t ofsCameras; + uint32_t nCameraLookup; + uint32_t ofsCameraLookup; + uint32_t nRibbonEmitters; + uint32_t ofsRibbonEmitters; + uint32_t nParticleEmitters; + uint32_t ofsParticleEmitters; + uint32_t nTextureCombinerCombos; + uint32_t ofsTextureCombinerCombos; }; // Total: 34 × 4 = 136 bytes @@ -79,6 +118,70 @@ struct M2Material { uint16_t blendingMode; // 0=opaque 1=alpha_key 2=alpha_blend }; +struct M2ArrayRef { + uint32_t count; + uint32_t offset; +}; + +struct M2Track { + uint16_t interpolationType; + uint16_t globalSequence; + M2ArrayRef timestamps; + M2ArrayRef values; +}; + +struct M2Color { + M2Track colorTrack; + M2Track alphaTrack; +}; + +struct M2TextureTransform { + M2Track translationTrack; + M2Track rotationTrack; + M2Track scaleTrack; +}; + +struct M2TextureWeight { + M2Track weightTrack; +}; + +struct M2CompBone { + uint32_t boneId; + uint32_t flags; + uint16_t parentIndex; + uint16_t submeshId; + uint32_t unknown; + M2Track translation; + M2Track rotation; + M2Track scale; + float pivot[3]; +}; + +struct M2Sequence { + uint16_t id; + uint16_t variationIndex; + uint32_t duration; + float moveSpeed; + uint32_t flags; + uint32_t frequency; + uint32_t replayMin; + uint32_t replayMax; + uint32_t blendTime; + float minBounds[3]; + float maxBounds[3]; + float boundsRadius; + uint16_t nextAnimation; + uint16_t aliasNext; +}; + +struct M2Float3 { + float v[3]; +}; + +struct M2CompQuat { + uint16_t v[4]; +}; + struct SkinHeader { uint32_t magic; // 'SKIN' LE = 0x4E494B53 uint32_t nIndices; @@ -164,10 +267,203 @@ static const T *safe_array(const std::vector &buf, uint32_t ofs, uint32 return reinterpret_cast(buf.data() + ofs); } +static Vector3 wow_vec3_to_godot(const float v[3]) { + return Vector3(v[0], v[2], -v[1]); +} + +static Vector3 wow_vec3_to_godot(float x, float y, float z) { + return Vector3(x, z, -y); +} + +static Vector4 normalize_quat(float x, float y, float z, float w) { + float len = std::sqrt(x * x + y * y + z * z + w * w); + if (len <= 0.000001f) { + return Vector4(0.0f, 0.0f, 0.0f, 1.0f); + } + float inv = 1.0f / len; + return Vector4(x * inv, y * inv, z * inv, w * inv); +} + +static Vector4 wow_quat_to_godot(float x, float y, float z, float w) { + // WoW model space is converted to Godot with Z/Y axis swap and Y handedness flip. + return normalize_quat(x, z, -y, w); +} + +static Vector4 decode_comp_quat(const uint16_t q[4]) { + float x = ((float)q[0] - 32768.0f) / 32767.0f; + float y = ((float)q[1] - 32768.0f) / 32767.0f; + float z = ((float)q[2] - 32768.0f) / 32767.0f; + float w = ((float)q[3] - 32768.0f) / 32767.0f; + return wow_quat_to_godot(x, y, z, w); +} + +template +static const T *track_sequence_array(const std::vector &buf, const M2ArrayRef &outer, uint32_t sequence_index, uint32_t &count_out) { + count_out = 0; + if (sequence_index >= outer.count) return nullptr; + const M2ArrayRef *inner_arrays = safe_array(buf, outer.offset, outer.count); + if (!inner_arrays) return nullptr; + const M2ArrayRef &inner = inner_arrays[sequence_index]; + const T *items = safe_array(buf, inner.offset, inner.count); + if (!items) return nullptr; + count_out = inner.count; + return items; +} + +static uint32_t track_sequence_key_count(const std::vector &buf, const M2Track &track, uint32_t sequence_index) { + if (sequence_index >= track.values.count) return 0; + const M2ArrayRef *inner_arrays = safe_array(buf, track.values.offset, track.values.count); + if (!inner_arrays) return 0; + return inner_arrays[sequence_index].count; +} + +template +static const T *first_track_value(const std::vector &buf, const M2Track &track) { + for (uint32_t i = 0; i < track.values.count; ++i) { + uint32_t value_count = 0; + const T *values = track_sequence_array(buf, track.values, i, value_count); + if (values && value_count > 0) { + return values; + } + } + return nullptr; +} + +static float fixed16_to_float(int16_t value) { + return std::clamp((float)value / 32767.0f, 0.0f, 1.0f); +} + +static Vector2 first_vec3_track_xy_speed(const std::vector &buf, const M2Track &track) { + for (uint32_t i = 0; i < track.values.count && i < track.timestamps.count; ++i) { + uint32_t time_count = 0; + uint32_t value_count = 0; + const uint32_t *times = track_sequence_array(buf, track.timestamps, i, time_count); + const M2Float3 *values = track_sequence_array(buf, track.values, i, value_count); + uint32_t key_count = std::min(time_count, value_count); + if (!times || !values || key_count < 2) { + continue; + } + uint32_t first = 0; + uint32_t last = key_count - 1; + float duration = (float)(times[last] - times[first]) / 1000.0f; + if (duration <= 0.0001f) { + continue; + } + return Vector2( + (values[last].v[0] - values[first].v[0]) / duration, + (values[last].v[1] - values[first].v[1]) / duration); + } + return Vector2(0.0f, 0.0f); +} + +static uint32_t sequence_activity_score(const std::vector &buf, const M2CompBone *bones, uint32_t bone_count, uint32_t sequence_index) { + if (!bones) return 0; + uint32_t score = 0; + for (uint32_t i = 0; i < bone_count; ++i) { + uint32_t t = track_sequence_key_count(buf, bones[i].translation, sequence_index); + uint32_t r = track_sequence_key_count(buf, bones[i].rotation, sequence_index); + uint32_t s = track_sequence_key_count(buf, bones[i].scale, sequence_index); + if (t > 1) score += t; + if (r > 1) score += r; + if (s > 1) score += s; + } + return score; +} + +static uint32_t choose_animated_sequence(const std::vector &buf, const M2Sequence *seqs, uint32_t seq_count, const M2CompBone *bones, uint32_t bone_count, bool prefer_calm_stand) { + if (!seqs || seq_count == 0) return 0; + + uint32_t best_stand = UINT32_MAX; + uint32_t best_stand_score = 0; + uint32_t calm_stand = UINT32_MAX; + uint32_t calm_stand_score = UINT32_MAX; + uint32_t best_any = UINT32_MAX; + uint32_t best_any_score = 0; + + for (uint32_t i = 0; i < seq_count; ++i) { + if (seqs[i].duration == 0) continue; + uint32_t score = sequence_activity_score(buf, bones, bone_count, i); + if (seqs[i].id == 0 && score > best_stand_score) { + best_stand = i; + best_stand_score = score; + } + if (seqs[i].id == 0 && score > 0 && score < calm_stand_score) { + calm_stand = i; + calm_stand_score = score; + } + if (score > best_any_score) { + best_any = i; + best_any_score = score; + } + } + + if (prefer_calm_stand && calm_stand != UINT32_MAX) return calm_stand; + if (best_stand != UINT32_MAX) return best_stand; + if (best_any != UINT32_MAX) return best_any; + + for (uint32_t i = 0; i < seq_count; ++i) { + if (seqs[i].id == 0 && seqs[i].duration > 0) return i; + } + for (uint32_t i = 0; i < seq_count; ++i) { + if (seqs[i].duration > 0) return i; + } + return 0; +} + +static Dictionary make_vec3_track(const std::vector &buf, const M2Track &track, uint32_t sequence_index, bool scale_track) { + Dictionary result; + uint32_t time_count = 0; + uint32_t value_count = 0; + const uint32_t *times = track_sequence_array(buf, track.timestamps, sequence_index, time_count); + const M2Float3 *values = track_sequence_array(buf, track.values, sequence_index, value_count); + if (!times || !values || time_count == 0 || value_count == 0) { + return result; + } + uint32_t key_count = std::min(time_count, value_count); + PackedFloat32Array out_times; + PackedVector3Array out_values; + out_times.resize(key_count); + out_values.resize(key_count); + for (uint32_t i = 0; i < key_count; ++i) { + out_times[i] = (float)times[i] / 1000.0f; + if (scale_track) { + out_values[i] = Vector3(values[i].v[0], values[i].v[1], values[i].v[2]); + } else { + out_values[i] = wow_vec3_to_godot(values[i].v[0], values[i].v[1], values[i].v[2]); + } + } + result["times"] = out_times; + result["values"] = out_values; + return result; +} + +static Dictionary make_quat_track(const std::vector &buf, const M2Track &track, uint32_t sequence_index) { + Dictionary result; + uint32_t time_count = 0; + uint32_t value_count = 0; + const uint32_t *times = track_sequence_array(buf, track.timestamps, sequence_index, time_count); + const M2CompQuat *values = track_sequence_array(buf, track.values, sequence_index, value_count); + if (!times || !values || time_count == 0 || value_count == 0) { + return result; + } + uint32_t key_count = std::min(time_count, value_count); + PackedFloat32Array out_times; + PackedVector4Array out_values; + out_times.resize(key_count); + out_values.resize(key_count); + for (uint32_t i = 0; i < key_count; ++i) { + out_times[i] = (float)times[i] / 1000.0f; + out_values[i] = decode_comp_quat(values[i].v); + } + result["times"] = out_times; + result["values"] = out_values; + return result; +} + // ───────────────────────────────────────────────────────────────────────────── // Core parser // ───────────────────────────────────────────────────────────────────────────── -Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string &path) { +Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string &path, bool include_animation) { if (buf.size() < sizeof(M2Header)) return Dictionary(); const auto &hdr = *reinterpret_cast(buf.data()); @@ -178,13 +474,14 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string // ── Vertices ───────────────────────────────────────────────────────────── PackedVector3Array vertices, normals; - PackedVector2Array uvs; + PackedVector2Array uvs, uvs2; const auto *verts = safe_array(buf, hdr.ofsVertices, hdr.nVertices); if (verts && hdr.nVertices > 0) { vertices.resize(hdr.nVertices); normals.resize(hdr.nVertices); uvs.resize(hdr.nVertices); + uvs2.resize(hdr.nVertices); for (uint32_t i = 0; i < hdr.nVertices; ++i) { const auto &v = verts[i]; // WoW model space (X right, Y forward, Z up) → Godot (X right, Y up, Z back) @@ -194,6 +491,7 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string // (D3D/Vulkan convention) — pass UVs through unchanged. Same as // wmo_loader's MOTV handling. uvs[i] = Vector2(v.texCoords[0], v.texCoords[1]); + uvs2[i] = Vector2(v.texCoords2[0], v.texCoords2[1]); } } @@ -244,7 +542,92 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string texture_combos.push_back((int)tc_arr[i]); } + PackedInt32Array texture_coord_combos; + const auto *tcc_arr = safe_array(buf, hdr.ofsTextureCoordCombos, hdr.nTextureCoordCombos); + if (tcc_arr) { + for (uint32_t i = 0; i < hdr.nTextureCoordCombos; ++i) + texture_coord_combos.push_back((int)tcc_arr[i]); + } + + PackedInt32Array texture_weight_combos; + const auto *twc_arr = safe_array(buf, hdr.ofsTextureWeightCombos, hdr.nTextureWeightCombos); + if (twc_arr) { + for (uint32_t i = 0; i < hdr.nTextureWeightCombos; ++i) + texture_weight_combos.push_back((int)twc_arr[i]); + } + + PackedInt32Array texture_transform_combos; + const auto *ttc_arr = safe_array(buf, hdr.ofsTextureTransformCombos, hdr.nTextureTransformCombos); + if (ttc_arr) { + for (uint32_t i = 0; i < hdr.nTextureTransformCombos; ++i) + texture_transform_combos.push_back((int)ttc_arr[i]); + } + + PackedInt32Array texture_combiner_combos; + const auto *tcomb_arr = safe_array(buf, hdr.ofsTextureCombinerCombos, hdr.nTextureCombinerCombos); + if (tcomb_arr) { + for (uint32_t i = 0; i < hdr.nTextureCombinerCombos; ++i) + texture_combiner_combos.push_back((int)tcomb_arr[i]); + } + // ── Skin file ──────────────────────────────────────────────────────────── + Array m2_colors; + const auto *color_arr = safe_array(buf, hdr.ofsColors, hdr.nColors); + if (color_arr) { + for (uint32_t i = 0; i < hdr.nColors; ++i) { + Color color(1.0f, 1.0f, 1.0f, 1.0f); + const M2Float3 *rgb = first_track_value(buf, color_arr[i].colorTrack); + if (rgb) { + color.r = rgb->v[0]; + color.g = rgb->v[1]; + color.b = rgb->v[2]; + } + const int16_t *alpha = first_track_value(buf, color_arr[i].alphaTrack); + if (alpha) { + color.a = fixed16_to_float(*alpha); + } + Dictionary c; + c["color"] = color; + m2_colors.push_back(c); + } + } + + PackedFloat32Array texture_weights; + const auto *weight_arr = safe_array(buf, hdr.ofsTexWeights, hdr.nTexWeights); + if (weight_arr) { + texture_weights.resize(hdr.nTexWeights); + for (uint32_t i = 0; i < hdr.nTexWeights; ++i) { + float weight = 1.0f; + const int16_t *value = first_track_value(buf, weight_arr[i].weightTrack); + if (value) { + weight = fixed16_to_float(*value); + } + texture_weights[i] = weight; + } + } + + Array texture_transforms; + const auto *transform_arr = safe_array(buf, hdr.ofsTexTransforms, hdr.nTexTransforms); + if (transform_arr) { + for (uint32_t i = 0; i < hdr.nTexTransforms; ++i) { + Vector2 translation(0.0f, 0.0f); + Vector2 scale(1.0f, 1.0f); + const M2Float3 *trans = first_track_value(buf, transform_arr[i].translationTrack); + if (trans) { + translation = Vector2(trans->v[0], trans->v[1]); + } + const M2Float3 *scl = first_track_value(buf, transform_arr[i].scaleTrack); + if (scl) { + scale = Vector2(scl->v[0], scl->v[1]); + } + Dictionary t; + t["translation"] = translation; + t["scale"] = scale; + t["translation_speed"] = first_vec3_track_xy_speed(buf, transform_arr[i].translationTrack); + texture_transforms.push_back(t); + } + } + // Find 00.skin in the same directory as the .m2 std::string skin_path = path; { @@ -257,7 +640,16 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string auto skin_buf = read_file(skin_path); PackedInt32Array indices; + PackedInt32Array bone_lookup_table; Array batches; + Array animated_surfaces; + const uint16_t *bone_combo_arr = safe_array(buf, hdr.ofsBoneCombos, hdr.nBoneCombos); + if (bone_combo_arr) { + bone_lookup_table.resize(hdr.nBoneCombos); + for (uint32_t i = 0; i < hdr.nBoneCombos; ++i) { + bone_lookup_table[i] = (int)bone_combo_arr[i]; + } + } if (skin_buf.size() >= sizeof(SkinHeader)) { const auto &skin = *reinterpret_cast(skin_buf.data()); @@ -265,6 +657,7 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string if (skin.magic == MAGIC_SKIN) { const uint16_t *skin_idx = safe_array(skin_buf, skin.ofsIndices, skin.nIndices); const uint16_t *skin_tri = safe_array(skin_buf, skin.ofsTriangles, skin.nTriangles); + const uint8_t *skin_bones = safe_array(skin_buf, skin.ofsProperties, skin.nProperties * 4); const SkinSubMesh *sms = safe_array (skin_buf, skin.ofsSubMeshes, skin.nSubMeshes); const SkinTextureUnit *tu = safe_array(skin_buf, skin.ofsTextureUnits, skin.nTextureUnits); @@ -303,7 +696,113 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string batch["index_count"] = idx_count; batch["material_id"] = (int)tu[u].materialIndex; batch["texture_combo_index"] = (int)tu[u].textureComboIndex; + batch["texture_count"] = (int)tu[u].textureCount; + batch["shader_id"] = (int)tu[u].shaderID; + batch["texture_coord_combo_index"] = (int)tu[u].textureCoordComboIndex; + batch["texture_weight_combo_index"] = (int)tu[u].textureWeightComboIndex; + batch["texture_transform_combo_index"] = (int)tu[u].textureTransformComboIndex; + batch["material_layer"] = (int)tu[u].materialLayer; + batch["color_index"] = (int)tu[u].colorIndex; + batch["priority_plane"] = (int)tu[u].priority; + batch["skin_flags"] = (int)tu[u].flags; + batch["skin_flags2"] = (int)tu[u].flags2; + batch["bone_count"] = (int)sm.boneCount; + batch["bone_combo_index"] = (int)sm.boneStart; batches.push_back(batch); + + if (include_animation && verts) { + PackedVector3Array surface_vertices; + PackedVector3Array surface_normals; + PackedVector2Array surface_uvs, surface_uvs2; + PackedInt32Array surface_bones; + PackedFloat32Array surface_weights; + PackedInt32Array surface_indices; + uint32_t expanded_count = 0; + surface_vertices.resize(tri_count); + surface_normals.resize(tri_count); + surface_uvs.resize(tri_count); + surface_uvs2.resize(tri_count); + surface_bones.resize(tri_count * 4); + surface_weights.resize(tri_count * 4); + surface_indices.resize(tri_count); + + for (uint32_t t = 0; t + 2 < tri_count; t += 3) { + uint16_t tri_local[3] = { + skin_tri[tri_start + t], + skin_tri[tri_start + t + 2], + skin_tri[tri_start + t + 1], + }; + for (uint32_t corner = 0; corner < 3; ++corner) { + uint16_t vertex_lookup = tri_local[corner]; + if (vertex_lookup >= skin.nIndices) { + continue; + } + uint16_t global_vertex_index = skin_idx[vertex_lookup]; + if (global_vertex_index >= hdr.nVertices) { + continue; + } + const auto &v = verts[global_vertex_index]; + surface_vertices[expanded_count] = Vector3(v.pos[0], v.pos[2], -v.pos[1]); + surface_normals[expanded_count] = Vector3(v.normal[0], v.normal[2], -v.normal[1]); + surface_uvs[expanded_count] = Vector2(v.texCoords[0], v.texCoords[1]); + surface_uvs2[expanded_count] = Vector2(v.texCoords2[0], v.texCoords2[1]); + surface_indices[expanded_count] = (int)expanded_count; + + uint32_t weight_sum = 0; + for (uint32_t j = 0; j < 4; ++j) { + weight_sum += (uint32_t)v.boneWeights[j]; + } + if (weight_sum == 0) { + weight_sum = 1; + } + + for (uint32_t j = 0; j < 4; ++j) { + int local_bone = (int)v.boneIndices[j]; + if (skin_bones && vertex_lookup < skin.nProperties) { + local_bone = (int)skin_bones[vertex_lookup * 4 + j]; + } + int global_bone = local_bone; + uint32_t lookup_index = (uint32_t)sm.boneStart + (uint32_t)std::max(local_bone, 0); + if (bone_combo_arr && lookup_index < hdr.nBoneCombos) { + global_bone = (int)bone_combo_arr[lookup_index]; + } + surface_bones[expanded_count * 4 + j] = global_bone; + surface_weights[expanded_count * 4 + j] = (float)v.boneWeights[j] / (float)weight_sum; + } + expanded_count++; + } + } + + if (expanded_count > 0) { + surface_vertices.resize(expanded_count); + surface_normals.resize(expanded_count); + surface_uvs.resize(expanded_count); + surface_uvs2.resize(expanded_count); + surface_bones.resize(expanded_count * 4); + surface_weights.resize(expanded_count * 4); + surface_indices.resize(expanded_count); + Dictionary surface; + surface["vertices"] = surface_vertices; + surface["normals"] = surface_normals; + surface["uvs"] = surface_uvs; + surface["uvs2"] = surface_uvs2; + surface["bones"] = surface_bones; + surface["weights"] = surface_weights; + surface["indices"] = surface_indices; + surface["material_id"] = (int)tu[u].materialIndex; + surface["texture_combo_index"] = (int)tu[u].textureComboIndex; + surface["texture_count"] = (int)tu[u].textureCount; + surface["shader_id"] = (int)tu[u].shaderID; + surface["texture_coord_combo_index"] = (int)tu[u].textureCoordComboIndex; + surface["texture_weight_combo_index"] = (int)tu[u].textureWeightComboIndex; + surface["texture_transform_combo_index"] = (int)tu[u].textureTransformComboIndex; + surface["material_layer"] = (int)tu[u].materialLayer; + surface["color_index"] = (int)tu[u].colorIndex; + surface["bone_count"] = (int)sm.boneCount; + surface["bone_combo_index"] = (int)sm.boneStart; + animated_surfaces.push_back(surface); + } + } } } } else { @@ -315,18 +814,81 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string to_godot(skin_path)); } + Array animated_bones; + Array animated_sequences; + int animation_sequence_index = -1; + float animation_length = 0.0f; + int animation_id = -1; + uint32_t animation_activity_score = 0; + if (include_animation) { + const M2Sequence *seqs = safe_array(buf, hdr.ofsAnimations, hdr.nAnimations); + const M2CompBone *bones = safe_array(buf, hdr.ofsBones, hdr.nBones); + if (seqs && hdr.nAnimations > 0) { + std::string lower_path = path; + std::transform(lower_path.begin(), lower_path.end(), lower_path.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + bool prefer_calm_stand = lower_path.find("gryphonroost") != std::string::npos; + uint32_t chosen = choose_animated_sequence(buf, seqs, hdr.nAnimations, bones, hdr.nBones, prefer_calm_stand); + animation_sequence_index = (int)chosen; + animation_length = (float)seqs[chosen].duration / 1000.0f; + animation_id = (int)seqs[chosen].id; + animation_activity_score = sequence_activity_score(buf, bones, hdr.nBones, chosen); + for (uint32_t i = 0; i < hdr.nAnimations; ++i) { + Dictionary seq; + seq["id"] = (int)seqs[i].id; + seq["variation"] = (int)seqs[i].variationIndex; + seq["duration"] = (int)seqs[i].duration; + seq["alias_next"] = (int)seqs[i].aliasNext; + seq["activity_score"] = (int)sequence_activity_score(buf, bones, hdr.nBones, i); + animated_sequences.push_back(seq); + } + } + if (bones && hdr.nBones > 0 && animation_sequence_index >= 0) { + for (uint32_t i = 0; i < hdr.nBones; ++i) { + Dictionary bone; + int parent = (bones[i].parentIndex == 0xFFFF) ? -1 : (int)bones[i].parentIndex; + bone["id"] = (int)bones[i].boneId; + bone["flags"] = (int)bones[i].flags; + bone["parent"] = parent; + bone["pivot"] = wow_vec3_to_godot(bones[i].pivot); + bone["translation"] = make_vec3_track(buf, bones[i].translation, (uint32_t)animation_sequence_index, false); + bone["rotation"] = make_quat_track(buf, bones[i].rotation, (uint32_t)animation_sequence_index); + bone["scale"] = make_vec3_track(buf, bones[i].scale, (uint32_t)animation_sequence_index, true); + animated_bones.push_back(bone); + } + } + } + Dictionary result; result["textures"] = textures; result["texture_types"] = texture_types; result["texture_flags"] = texture_flags; result["materials"] = materials; result["texture_combos"] = texture_combos; + result["texture_coord_combos"] = texture_coord_combos; + result["texture_weight_combos"] = texture_weight_combos; + result["texture_transform_combos"] = texture_transform_combos; + result["texture_combiner_combos"] = texture_combiner_combos; + result["m2_colors"] = m2_colors; + result["texture_weights"] = texture_weights; + result["texture_transforms"] = texture_transforms; + result["m2_flags"] = (int)hdr.flags; result["model_path"] = to_godot(path); result["vertices"] = vertices; result["normals"] = normals; result["uvs"] = uvs; + result["uvs2"] = uvs2; result["indices"] = indices; result["batches"] = batches; + if (include_animation) { + result["animated_surfaces"] = animated_surfaces; + result["bone_lookup_table"] = bone_lookup_table; + result["bones"] = animated_bones; + result["sequences"] = animated_sequences; + result["animation_sequence_index"] = animation_sequence_index; + result["animation_id"] = animation_id; + result["animation_length"] = animation_length; + result["animation_activity_score"] = (int)animation_activity_score; + } return result; } @@ -340,9 +902,20 @@ Dictionary M2Loader::load_m2(const String &path) { UtilityFunctions::push_error("M2Loader: cannot read ", path); return Dictionary(); } - return parse_m2(buf, spath); + return parse_m2(buf, spath, false); +} + +Dictionary M2Loader::load_m2_animated(const String &path) { + std::string spath = to_std(path); + auto buf = read_file(spath); + if (buf.empty()) { + UtilityFunctions::push_error("M2Loader: cannot read ", path); + return Dictionary(); + } + return parse_m2(buf, spath, true); } void M2Loader::_bind_methods() { ClassDB::bind_method(D_METHOD("load_m2", "path"), &M2Loader::load_m2); + ClassDB::bind_method(D_METHOD("load_m2_animated", "path"), &M2Loader::load_m2_animated); } diff --git a/src/native/src/m2_loader.h b/src/native/src/m2_loader.h index 21ec411..d7eed4e 100644 --- a/src/native/src/m2_loader.h +++ b/src/native/src/m2_loader.h @@ -28,10 +28,14 @@ namespace godot { // "texture_flags": PackedInt32Array, # M2Texture.flags per texture slot // "materials": Array[Dictionary], # [{flags, blend_mode}] // "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures +// "texture_coord_combos": PackedInt32Array, +// "texture_combiner_combos": PackedInt32Array, +// "m2_flags": int, // "model_path": String, # absolute source .m2 path // "vertices": PackedVector3Array, # Godot-space positions // "normals": PackedVector3Array, // "uvs": PackedVector2Array, +// "uvs2": PackedVector2Array, // "indices": PackedInt32Array, # flat triangle list into vertex array // "batches": Array[Dictionary], # render batches // } @@ -41,6 +45,9 @@ namespace godot { // "index_start": int, # offset into indices array // "index_count": int, # number of indices for this batch // "material_id": int, # index into materials +// "texture_count": int, +// "shader_id": int, +// "texture_coord_combo_index": int, // "texture_combo_index": int, # index into texture_combos → texture // } // ───────────────────────────────────────────────────────────────────────────── @@ -49,12 +56,13 @@ class M2Loader : public RefCounted { public: Dictionary load_m2(const String &path); + Dictionary load_m2_animated(const String &path); protected: static void _bind_methods(); private: - Dictionary parse_m2(const std::vector &buf, const std::string &path); + Dictionary parse_m2(const std::vector &buf, const std::string &path, bool include_animation); static std::vector read_file(const std::string &path); static std::string to_std(const String &s); diff --git a/src/native/src/wmo_loader.cpp b/src/native/src/wmo_loader.cpp index 98a6b9f..d546083 100644 --- a/src/native/src/wmo_loader.cpp +++ b/src/native/src/wmo_loader.cpp @@ -2,6 +2,7 @@ #include "wow_chunk_reader.h" #include +#include #include #include #include @@ -141,6 +142,22 @@ String WMOLoader::to_godot(const std::string &s) { return String(s.c_str()); } +static Color color_from_bgra_u32(uint32_t value) { + const float b = (value & 0xFFu) / 255.0f; + const float g = ((value >> 8) & 0xFFu) / 255.0f; + const float r = ((value >> 16) & 0xFFu) / 255.0f; + const float a = ((value >> 24) & 0xFFu) / 255.0f; + return Color(r, g, b, a); +} + +static Color color_from_rgba_u32(uint32_t value) { + const float r = (value & 0xFFu) / 255.0f; + const float g = ((value >> 8) & 0xFFu) / 255.0f; + const float b = ((value >> 16) & 0xFFu) / 255.0f; + const float a = ((value >> 24) & 0xFFu) / 255.0f; + return Color(r, g, b, a); +} + // ───────────────────────────────────────────────────────────────────────────── // Root parser // ───────────────────────────────────────────────────────────────────────────── @@ -180,6 +197,10 @@ Dictionary WMOLoader::parse_root(const std::vector &buf) { mat["flags"] = (int)mt[i].flags; mat["shader"] = (int)mt[i].shader; mat["blend_mode"] = (int)mt[i].blendMode; + mat["emissive_color"] = color_from_bgra_u32(mt[i].sidnColor); + mat["diffuse_color"] = color_from_rgba_u32(mt[i].diffColor); + mat["color2"] = color_from_rgba_u32(mt[i].color2); + mat["flags2"] = (int)mt[i].flags2; // texUnit offsets → find index in tex_strings auto tex_idx = [&](uint32_t ofs) -> int { uint32_t cur = 0; @@ -191,6 +212,7 @@ Dictionary WMOLoader::parse_root(const std::vector &buf) { }; mat["texture0"] = tex_idx(mt[i].texUnit0); mat["texture1"] = tex_idx(mt[i].texUnit1); + mat["texture2"] = tex_idx(mt[i].texUnit2); materials.push_back(mat); } } else if (chunk.is("MODS")) { diff --git a/src/native/src/wmo_loader.h b/src/native/src/wmo_loader.h index 89dd901..fe3851d 100644 --- a/src/native/src/wmo_loader.h +++ b/src/native/src/wmo_loader.h @@ -57,8 +57,12 @@ namespace godot { // { // "texture0": int, # index into textures array (-1 = none) // "texture1": int, +// "texture2": int, // "blend_mode": int, # 0=opaque 1=alpha key 2=alpha blend // "flags": int, +// "flags2": int, +// "diffuse_color": Color, +// "emissive_color": Color, // } // ───────────────────────────────────────────────────────────────────────────── class WMOLoader : public RefCounted { diff --git a/src/resources/wmo_streaming_resource.gd b/src/resources/wmo_streaming_resource.gd index 4748524..0b0650a 100644 --- a/src/resources/wmo_streaming_resource.gd +++ b/src/resources/wmo_streaming_resource.gd @@ -1,7 +1,7 @@ extends Resource class_name WMOStreamingResource -const FORMAT_VERSION := 1 +const FORMAT_VERSION := 2 @export var format_version: int = FORMAT_VERSION @export var source_path: String = "" diff --git a/src/scenes/character/character_creator.gd b/src/scenes/character/character_creator.gd new file mode 100644 index 0000000..fc07c86 --- /dev/null +++ b/src/scenes/character/character_creator.gd @@ -0,0 +1,654 @@ +extends Control + +const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd") +const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd") +const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd") +const CHARACTER_ROOT := "res://src/resources/characters" +const EXTRACTED_DIR := "res://data/extracted" +const CHARACTER_YAW_OFFSET_DEGREES := 90.0 + +const CLASS_IDS := { + "Warrior": 1, + "Paladin": 2, + "Hunter": 3, + "Rogue": 4, + "Priest": 5, + "Death Knight": 6, + "Shaman": 7, + "Mage": 8, + "Warlock": 9, + "Druid": 11, +} + +const GEOSET_CONTROLS := [ + {"cat": "hair", "label": "Hair"}, + {"cat": "facial1", "label": "Facial 1"}, + {"cat": "facial2", "label": "Facial 2"}, + {"cat": "facial3", "label": "Facial 3"}, + {"cat": "ears", "label": "Ears"}, + {"cat": "eye_effects", "label": "Eyes"}, + {"cat": "wristbands", "label": "Wrist"}, +] + +const TOGGLE_CONTROLS := [ + {"property": "show_gloves", "label": "Gloves"}, + {"property": "show_boots", "label": "Boots"}, + {"property": "show_shirt", "label": "Shirt"}, + {"property": "show_kneepads", "label": "Knees"}, + {"property": "show_chest", "label": "Chest"}, + {"property": "show_pants", "label": "Pants"}, + {"property": "show_tabard", "label": "Tabard"}, + {"property": "show_legs", "label": "Legs"}, + {"property": "show_cape", "label": "Cape"}, + {"property": "show_belt", "label": "Belt"}, + {"property": "show_feet", "label": "Feet"}, +] + +var _models: Dictionary = {} +var _race_names: Array[String] = [] +var _selected_race := "" +var _selected_gender := "" +var _selected_model: Dictionary = {} + +var _viewport: SubViewport +var _preview_anchor: Node3D +var _camera: Camera3D +var _model_root: Node3D +var _controller: Node +var _compositor: Node +var _outfit_resolver: RefCounted +var _current_outfit: Dictionary = {} + +var _race_option: OptionButton +var _gender_option: OptionButton +var _model_option: OptionButton +var _class_option: OptionButton +var _skin_spin: SpinBox +var _face_spin: SpinBox +var _hair_color_spin: SpinBox +var _flags_spin: SpinBox +var _rotation_slider: HSlider +var _status_label: Label +var _control_container: VBoxContainer +var _geoset_options: Dictionary = {} +var _toggle_checks: Dictionary = {} + + +func _ready() -> void: + _outfit_resolver = OUTFIT_RESOLVER_SCRIPT.new() + if not _outfit_resolver.call("load_dbcs", EXTRACTED_DIR): + push_warning("CharacterCreator: CharStartOutfit/ItemDisplayInfo DBCs not loaded") + _scan_models() + _build_ui() + _populate_races() + if not _race_names.is_empty(): + _select_race(_race_names[0]) + + +func _process(delta: float) -> void: + if _preview_anchor: + _preview_anchor.rotation.y = deg_to_rad(float(_rotation_slider.value)) + + +func _scan_models() -> void: + _models.clear() + _race_names.clear() + var root := DirAccess.open(CHARACTER_ROOT) + if root == null: + push_warning("CharacterCreator: missing character root: %s" % CHARACTER_ROOT) + return + + root.list_dir_begin() + var race_dir := root.get_next() + while not race_dir.is_empty(): + if root.current_is_dir() and not race_dir.begins_with("."): + _scan_race(race_dir) + race_dir = root.get_next() + root.list_dir_end() + _race_names.sort_custom(func(a: String, b: String) -> bool: return _display_name(a) < _display_name(b)) + + +func _scan_race(race: String) -> void: + var race_path := CHARACTER_ROOT.path_join(race) + var race_access := DirAccess.open(race_path) + if race_access == null: + return + + var race_data: Dictionary = {} + race_access.list_dir_begin() + var gender_dir := race_access.get_next() + while not gender_dir.is_empty(): + if race_access.current_is_dir() and not gender_dir.begins_with("."): + var models := _scan_gender_models(race, gender_dir) + if not models.is_empty(): + race_data[gender_dir] = models + gender_dir = race_access.get_next() + race_access.list_dir_end() + + if not race_data.is_empty(): + _models[race] = race_data + _race_names.append(race) + + +func _scan_gender_models(race: String, gender: String) -> Array: + var result: Array = [] + var gender_path := CHARACTER_ROOT.path_join(race).path_join(gender) + var access := DirAccess.open(gender_path) + if access == null: + return result + + access.list_dir_begin() + var file := access.get_next() + while not file.is_empty(): + if not access.current_is_dir() and file.get_extension().to_lower() == "glb": + var glb_path := gender_path.path_join(file) + result.append({ + "race": race, + "gender": gender, + "name": file.get_basename(), + "path": glb_path, + "textures_dir": gender_path.path_join(file.get_basename() + "_textures"), + }) + file = access.get_next() + access.list_dir_end() + result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return String(a["name"]) < String(b["name"])) + return result + + +func _build_ui() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + + var root := HSplitContainer.new() + root.name = "Layout" + root.split_offset = 780 + root.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(root) + + var viewport_container := SubViewportContainer.new() + viewport_container.name = "Preview" + viewport_container.stretch = true + viewport_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + root.add_child(viewport_container) + + _viewport = SubViewport.new() + _viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + _viewport.size = Vector2i(900, 720) + viewport_container.add_child(_viewport) + _build_preview_world() + + var panel := PanelContainer.new() + panel.name = "Controls" + panel.custom_minimum_size = Vector2(360, 0) + root.add_child(panel) + + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + panel.add_child(scroll) + + _control_container = VBoxContainer.new() + _control_container.add_theme_constant_override("separation", 8) + scroll.add_child(_control_container) + + var title := Label.new() + title.text = "Character Creator" + title.add_theme_font_size_override("font_size", 22) + _control_container.add_child(title) + + _race_option = _add_option("Race", _on_race_selected) + _gender_option = _add_option("Gender", _on_gender_selected) + _model_option = _add_option("Model", _on_model_selected) + _class_option = _add_option("Class", _on_class_selected) + for class_label in CLASS_IDS.keys(): + _class_option.add_item(class_label) + _class_option.set_item_metadata(_class_option.item_count - 1, int(CLASS_IDS[class_label])) + + _skin_spin = _add_spin("Skin", 0, 0, 1, _on_skin_changed) + _face_spin = _add_spin("Face", 0, 0, 1, _on_face_changed) + _hair_color_spin = _add_spin("Hair Color", 0, 15, 1, _on_hair_color_changed) + _flags_spin = _add_spin("Flags", 0, 31, 1, _on_flags_changed) + + for spec in GEOSET_CONTROLS: + var option := _add_option(String(spec["label"]), _on_geoset_selected.bind(String(spec["cat"]))) + _geoset_options[String(spec["cat"])] = option + + var equipment_label := Label.new() + equipment_label.text = "Equipment Geosets" + equipment_label.add_theme_font_size_override("font_size", 16) + _control_container.add_child(equipment_label) + + var toggle_grid := GridContainer.new() + toggle_grid.columns = 2 + _control_container.add_child(toggle_grid) + for spec in TOGGLE_CONTROLS: + var check := CheckBox.new() + check.text = String(spec["label"]) + check.toggled.connect(_on_toggle_changed.bind(String(spec["property"]))) + toggle_grid.add_child(check) + _toggle_checks[String(spec["property"])] = check + + _rotation_slider = _add_slider("Rotation", -180.0, 180.0, 0.0) + + var reload := Button.new() + reload.text = "Rescan Models" + reload.pressed.connect(_on_rescan_pressed) + _control_container.add_child(reload) + + var starter := Button.new() + starter.text = "Apply Starter Outfit" + starter.pressed.connect(_apply_starter_outfit) + _control_container.add_child(starter) + + _status_label = Label.new() + _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _status_label.text = "" + _control_container.add_child(_status_label) + + +func _build_preview_world() -> void: + var world := Node3D.new() + world.name = "PreviewWorld" + _viewport.add_child(world) + + var env := WorldEnvironment.new() + var environment := Environment.new() + environment.background_mode = Environment.BG_COLOR + environment.background_color = Color(0.04, 0.045, 0.055) + environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.ambient_light_color = Color(0.55, 0.58, 0.62) + environment.ambient_light_energy = 1.0 + env.environment = environment + world.add_child(env) + + var key_light := DirectionalLight3D.new() + key_light.rotation_degrees = Vector3(-35, -35, 0) + key_light.light_energy = 2.4 + world.add_child(key_light) + + var fill_light := OmniLight3D.new() + fill_light.position = Vector3(-2.5, 1.8, 2.2) + fill_light.light_energy = 1.2 + fill_light.omni_range = 5.0 + world.add_child(fill_light) + + _preview_anchor = Node3D.new() + _preview_anchor.name = "CharacterAnchor" + world.add_child(_preview_anchor) + + _camera = Camera3D.new() + _camera.name = "Camera" + _camera.position = Vector3(0, 1.25, 3.2) + _camera.rotation_degrees = Vector3(-7, 0, 0) + _camera.fov = 38.0 + _camera.current = true + world.add_child(_camera) + + +func _add_option(label_text: String, callback: Callable) -> OptionButton: + var label := Label.new() + label.text = label_text + _control_container.add_child(label) + var option := OptionButton.new() + option.item_selected.connect(callback) + _control_container.add_child(option) + return option + + +func _add_spin(label_text: String, min_value: float, max_value: float, step: float, callback: Callable) -> SpinBox: + var label := Label.new() + label.text = label_text + _control_container.add_child(label) + var spin := SpinBox.new() + spin.min_value = min_value + spin.max_value = max_value + spin.step = step + spin.value_changed.connect(callback) + _control_container.add_child(spin) + return spin + + +func _add_slider(label_text: String, min_value: float, max_value: float, value: float) -> HSlider: + var label := Label.new() + label.text = label_text + _control_container.add_child(label) + var slider := HSlider.new() + slider.min_value = min_value + slider.max_value = max_value + slider.step = 1.0 + slider.value = value + _control_container.add_child(slider) + return slider + + +func _populate_races() -> void: + _race_option.clear() + for race in _race_names: + _race_option.add_item(_display_name(race)) + _race_option.set_item_metadata(_race_option.item_count - 1, race) + + +func _select_race(race: String) -> void: + _selected_race = race + _gender_option.clear() + var genders: Array[String] = [] + for gender in (_models.get(race, {}) as Dictionary).keys(): + genders.append(String(gender)) + genders.sort() + for gender in genders: + _gender_option.add_item(_display_name(gender)) + _gender_option.set_item_metadata(_gender_option.item_count - 1, gender) + if not genders.is_empty(): + _select_gender(genders[0]) + + +func _select_gender(gender: String) -> void: + _selected_gender = gender + _model_option.clear() + var models: Array = (_models.get(_selected_race, {}) as Dictionary).get(gender, []) + for model in models: + _model_option.add_item(String(model["name"])) + _model_option.set_item_metadata(_model_option.item_count - 1, model) + if not models.is_empty(): + _select_model(models[0]) + + +func _select_model(model: Dictionary) -> void: + _selected_model = model + _load_model(model) + + +func _load_model(model: Dictionary) -> void: + if _model_root and is_instance_valid(_model_root): + _model_root.queue_free() + _model_root = null + _controller = null + _compositor = null + + var scene: PackedScene = load(String(model["path"])) + if scene == null: + _status_label.text = "Failed to load model: %s" % String(model["path"]) + return + + _model_root = Node3D.new() + _model_root.name = "%s_%s" % [String(model["race"]), String(model["gender"])] + _model_root.set_script(GEOSET_CONTROLLER_SCRIPT) + _model_root.rotation_degrees.y = CHARACTER_YAW_OFFSET_DEGREES + + var instance := scene.instantiate() + instance.name = String(model["name"]) + _model_root.add_child(instance) + + var compositor := Node.new() + compositor.name = "CharacterTextureCompositor" + compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT) + compositor.set("textures_dir", String(model["textures_dir"])) + compositor.set("extracted_dir", EXTRACTED_DIR) + _model_root.add_child(compositor) + + _preview_anchor.add_child(_model_root) + _controller = _model_root + _compositor = compositor + _fit_camera_to_model() + await get_tree().process_frame + _refresh_dynamic_controls() + _apply_all_controls() + _apply_starter_outfit() + _update_status() + + +func _fit_camera_to_model() -> void: + if _model_root == null: + return + var aabb := _calculate_aabb(_model_root) + if aabb.size == Vector3.ZERO: + _camera.position = Vector3(0, 1.25, 3.2) + return + var center := aabb.get_center() + _model_root.position = Vector3(-center.x, -aabb.position.y, -center.z) + var height := maxf(aabb.size.y, 1.0) + _camera.position = Vector3(0, height * 0.55, height * 1.85) + _camera.look_at(Vector3(0, height * 0.52, 0), Vector3.UP) + + +func _calculate_aabb(root: Node) -> AABB: + var result := AABB() + var has_aabb := false + for mesh_inst in _iter_mesh_instances(root): + if mesh_inst.mesh == null: + continue + var local_aabb := mesh_inst.mesh.get_aabb() + var global_aabb := mesh_inst.global_transform * local_aabb + if not has_aabb: + result = global_aabb + has_aabb = true + else: + result = result.merge(global_aabb) + return result if has_aabb else AABB() + + +func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]: + var result: Array[MeshInstance3D] = [] + _collect_mesh_instances(root, result) + return result + + +func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void: + if node is MeshInstance3D: + result.append(node) + for child in node.get_children(): + _collect_mesh_instances(child, result) + + +func _refresh_dynamic_controls() -> void: + if _controller == null: + return + + _skin_spin.max_value = maxi(0, int(_controller.call("get_skin_color_count")) - 1) + _face_spin.max_value = maxi(0, int(_controller.call("get_face_style_count")) - 1) + _skin_spin.value = clampf(_skin_spin.value, _skin_spin.min_value, _skin_spin.max_value) + _face_spin.value = clampf(_face_spin.value, _face_spin.min_value, _face_spin.max_value) + + for spec in GEOSET_CONTROLS: + var cat := String(spec["cat"]) + var option: OptionButton = _geoset_options[cat] + option.clear() + var ids: Array = _controller.call("get_ids", cat) + for id in ids: + option.add_item(str(id)) + option.set_item_metadata(option.item_count - 1, int(id)) + option.disabled = ids.is_empty() + if not ids.is_empty(): + option.select(0) + + +func _apply_all_controls() -> void: + _on_skin_changed(_skin_spin.value) + _on_face_changed(_face_spin.value) + _on_hair_color_changed(_hair_color_spin.value) + _on_flags_changed(_flags_spin.value) + for cat in _geoset_options.keys(): + var option: OptionButton = _geoset_options[cat] + if option.item_count > 0: + _apply_geoset(cat, int(option.get_item_metadata(option.selected))) + for property in _toggle_checks.keys(): + var check: CheckBox = _toggle_checks[property] + _set_controller_property(property, check.button_pressed) + + +func _on_race_selected(index: int) -> void: + _select_race(String(_race_option.get_item_metadata(index))) + + +func _on_gender_selected(index: int) -> void: + _select_gender(String(_gender_option.get_item_metadata(index))) + + +func _on_model_selected(index: int) -> void: + _select_model(_model_option.get_item_metadata(index)) + + +func _on_class_selected(_index: int) -> void: + _apply_starter_outfit() + _update_status() + + +func _on_skin_changed(value: float) -> void: + _set_controller_property("skin_color", int(value)) + _update_status() + + +func _on_face_changed(value: float) -> void: + _set_controller_property("face_style", int(value)) + _update_status() + + +func _on_hair_color_changed(_value: float) -> void: + _update_status() + + +func _on_flags_changed(_value: float) -> void: + _update_status() + + +func _on_geoset_selected(index: int, cat: String) -> void: + var option: OptionButton = _geoset_options[cat] + if index < 0 or index >= option.item_count: + return + _apply_geoset(cat, int(option.get_item_metadata(index))) + _update_status() + + +func _on_toggle_changed(pressed: bool, property: String) -> void: + _set_controller_property(property, pressed) + + +func _on_rescan_pressed() -> void: + _scan_models() + _populate_races() + if _models.has(_selected_race): + _select_race(_selected_race) + elif not _race_names.is_empty(): + _select_race(_race_names[0]) + + +func _apply_starter_outfit() -> void: + _current_outfit.clear() + if _outfit_resolver == null or _controller == null or _compositor == null: + return + var class_id := int(_class_option.get_item_metadata(maxi(0, _class_option.selected))) + var outfit: Dictionary = _outfit_resolver.call( + "resolve_start_outfit", + _selected_race, + _selected_gender, + class_id) + if outfit.is_empty(): + _update_status() + return + _current_outfit = outfit + if _compositor.has_method("set_equipment_components"): + _compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray())) + if _controller.has_method("apply_outfit_defaults"): + _controller.call("apply_outfit_defaults", outfit) + _sync_outfit_controls() + _update_status() + + +func _sync_outfit_controls() -> void: + if _controller == null: + return + for property in _toggle_checks.keys(): + var check: CheckBox = _toggle_checks[property] + check.set_pressed_no_signal(bool(_controller.get(property))) + var wrist: int = int(_controller.get("wristband_style")) + if _geoset_options.has("wristbands"): + _select_option_by_metadata(_geoset_options["wristbands"], wrist) + + +func _apply_geoset(cat: String, id: int) -> void: + match cat: + "hair": + _set_controller_property("hair_style", id) + "facial1": + _set_controller_property("facial1", id) + "facial2": + _set_controller_property("facial2", id) + "facial3": + _set_controller_property("facial3", id) + "ears": + _set_controller_property("ears", id) + "eye_effects": + _set_controller_property("eye_effects", id) + "wristbands": + _set_controller_property("wristband_style", id) + + +func _set_controller_property(property: String, value: Variant) -> void: + if _controller == null: + return + _controller.set(property, value) + + +func _update_status() -> void: + if _status_label == null: + return + var packed := _pack_appearance() + var equipment := _pack_equipment() + var outfit_display_count := 0 + var outfit_texture_count := 0 + if not _current_outfit.is_empty(): + outfit_display_count = (_current_outfit.get("display_ids", PackedInt32Array()) as PackedInt32Array).size() + for rel_path in (_current_outfit.get("textures", PackedStringArray()) as PackedStringArray): + if not String(rel_path).is_empty(): + outfit_texture_count += 1 + _status_label.text = "Appearance 0x%08X\nEquipment 0x%08X\nStarter outfit: %d displays, %d textures\n%s / %s / %s" % [ + packed, + equipment, + outfit_display_count, + outfit_texture_count, + _display_name(_selected_race), + _display_name(_selected_gender), + String(_selected_model.get("name", "")), + ] + + +func _pack_appearance() -> int: + var skin := int(_skin_spin.value) & 0x1f + var face := int(_face_spin.value) & 0x1f + var hair := _selected_local_geoset_id("hair", 1) & 0x1f + var hair_color := int(_hair_color_spin.value) & 0x0f + var facial := _selected_local_geoset_id("facial1", 101) & 0x0f + var class_id := int(_class_option.get_item_metadata(maxi(0, _class_option.selected))) & 0x0f + var flags := int(_flags_spin.value) & 0x1f + return skin | (face << 5) | (hair << 10) | (hair_color << 15) | (facial << 19) | (class_id << 23) | (flags << 27) + + +func _pack_equipment() -> int: + var upper := 1 if bool((_toggle_checks.get("show_chest") as CheckBox).button_pressed) else 0 + var lower := 1 if bool((_toggle_checks.get("show_pants") as CheckBox).button_pressed) else 0 + var hands := 1 if bool((_toggle_checks.get("show_gloves") as CheckBox).button_pressed) else 0 + var feet := 1 if bool((_toggle_checks.get("show_boots") as CheckBox).button_pressed) else 0 + return upper | (lower << 8) | (hands << 16) | (feet << 24) + + +func _selected_local_geoset_id(cat: String, base_id: int) -> int: + if not _geoset_options.has(cat): + return 0 + var option: OptionButton = _geoset_options[cat] + if option.item_count <= 0 or option.selected < 0: + return 0 + return maxi(0, int(option.get_item_metadata(option.selected)) - base_id) + + +func _select_option_by_metadata(option: OptionButton, metadata: int) -> void: + for i in option.item_count: + if int(option.get_item_metadata(i)) == metadata: + option.select(i) + return + + +func _display_name(value: String) -> String: + var s := value.replace("_", " ").to_lower() + var parts := s.split(" ", false) + for i in parts.size(): + parts[i] = String(parts[i]).capitalize() + return " ".join(parts) diff --git a/src/scenes/character/character_creator.gd.uid b/src/scenes/character/character_creator.gd.uid new file mode 100644 index 0000000..fc89a59 --- /dev/null +++ b/src/scenes/character/character_creator.gd.uid @@ -0,0 +1 @@ +uid://cwvvg0npx5vlr diff --git a/src/scenes/character/character_creator.tscn b/src/scenes/character/character_creator.tscn new file mode 100644 index 0000000..f2eb1f5 --- /dev/null +++ b/src/scenes/character/character_creator.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/scenes/character/character_creator.gd" id="1_creator"] + +[node name="CharacterCreator" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_creator") diff --git a/src/scenes/character/character_geoset_controller.gd b/src/scenes/character/character_geoset_controller.gd index 720f986..e73b59d 100644 --- a/src/scenes/character/character_geoset_controller.gd +++ b/src/scenes/character/character_geoset_controller.gd @@ -181,7 +181,7 @@ func _apply_variant(cat: String, wanted: int) -> void: for child in _geoset_nodes(): var gid := _geoset_id(child.name) if _category(gid) == cat: - child.visible = (gid == wanted) + child.visible = (gid == wanted) and _geoset_renderable(child, cat) ## Show or hide all geosets in a category. func _apply_toggle(cat: String, on: bool) -> void: @@ -204,7 +204,7 @@ func _apply_all(nodes: Array[Node] = []) -> void: "facial2": child.visible = (gid == facial2) "facial3": child.visible = (gid == facial3) "ears": child.visible = (gid == ears) - "eye_effects": child.visible = (gid == eye_effects) + "eye_effects": child.visible = (gid == eye_effects) and _geoset_renderable(child, cat) "gloves": child.visible = show_gloves "boots": child.visible = show_boots "shirt": child.visible = show_shirt @@ -301,18 +301,20 @@ func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void: if not is_eye_glow: continue - # Replace every surface material with the glow shader - for si in range(mesh_inst.get_surface_override_material_count()): - var orig_mat := mesh_inst.mesh.surface_get_material(si) + if not _geoset_renderable(mesh_inst, "eye_effects"): + mesh_inst.visible = false + continue + + for si in range(mesh_inst.mesh.get_surface_count()): + var orig_mat := _surface_material(mesh_inst, si) + if not _material_has_albedo_texture(orig_mat): + continue var shader_mat := ShaderMaterial.new() shader_mat.shader = EYE_GLOW_SHADER shader_mat.set_shader_parameter("glow_color", Vector3(glow_color.r, glow_color.g, glow_color.b)) shader_mat.set_shader_parameter("glow_intensity", eye_glow_intensity) - # Carry over the original albedo texture if it was a StandardMaterial3D - if orig_mat is StandardMaterial3D: - var std := orig_mat as StandardMaterial3D - if std.albedo_texture: - shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture) + var std := orig_mat as StandardMaterial3D + shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture) mesh_inst.set_surface_override_material(si, shader_mat) ## Returns sorted list of geoset IDs available for a given category. @@ -338,6 +340,34 @@ func get_skin_color_count() -> int: return _skin_color_count ## Returns valid face style count for this model (0 if compositor not found yet). func get_face_style_count() -> int: return _face_style_count + +func apply_outfit_defaults(outfit: Dictionary = {}) -> void: + show_gloves = true + show_boots = true + show_shirt = false + show_kneepads = true + show_chest = false + show_pants = false + show_tabard = false + show_legs = (int(outfit.get("flags", 0)) & 0x4) == 0 + show_cape = true + show_belt = false + show_feet = false + wristband_style = _clamp_geoset("wristbands", 802) + _apply_exact_or_first("gloves", 401) + _apply_exact_or_first("boots", 501) + _apply_exact_or_first("ears", 702) + _apply_exact_or_first("wristbands", wristband_style) + _apply_exact_or_first("kneepads", 902) + _apply_exact_or_first("legs", 1301) + _apply_exact_or_first("cape", 1501) + _apply_toggle("shirt", false) + _apply_toggle("chest", false) + _apply_toggle("pants", false) + _apply_toggle("tabard", false) + _apply_toggle("belt", false) + _apply_toggle("feet", false) + ## Clamp/snap v to the nearest available geoset ID in cat. ## Returns v unchanged if _available is not populated yet or cat is missing. func _clamp_geoset(cat: String, v: int) -> int: @@ -357,3 +387,31 @@ func _clamp_geoset(cat: String, v: int) -> int: best_dist = d best = id return best + + +func _apply_exact_or_first(cat: String, wanted: int) -> void: + var id := _clamp_geoset(cat, wanted) + _apply_variant(cat, id) + + +func _geoset_renderable(node: Node, cat: String) -> bool: + if cat != "eye_effects": + return true + var mesh_inst := node as MeshInstance3D + if mesh_inst == null or mesh_inst.mesh == null: + return false + for si in range(mesh_inst.mesh.get_surface_count()): + if _material_has_albedo_texture(_surface_material(mesh_inst, si)): + return true + return false + + +func _surface_material(mesh_inst: MeshInstance3D, surface: int) -> Material: + var mat := mesh_inst.get_surface_override_material(surface) + if mat == null and mesh_inst.mesh != null: + mat = mesh_inst.mesh.surface_get_material(surface) + return mat + + +func _material_has_albedo_texture(mat: Material) -> bool: + return mat is StandardMaterial3D and (mat as StandardMaterial3D).albedo_texture != null diff --git a/src/scenes/character/character_texture_compositor.gd b/src/scenes/character/character_texture_compositor.gd index 3d0300a..e1c9cc2 100644 --- a/src/scenes/character/character_texture_compositor.gd +++ b/src/scenes/character/character_texture_compositor.gd @@ -20,6 +20,16 @@ const NAKED_TORSO_POS := Vector2i(0, 0) const NAKED_PELVIS_POS := Vector2i(0, 128) const FACE_UPPER_POS := Vector2i(0, 320) const FACE_LOWER_POS := Vector2i(0, 384) +const COMPONENT_RECTS := [ + Rect2i(0, 0, 256, 128), + Rect2i(0, 128, 256, 128), + Rect2i(0, 256, 256, 64), + Rect2i(256, 0, 256, 128), + Rect2i(256, 128, 256, 64), + Rect2i(256, 192, 256, 128), + Rect2i(256, 320, 256, 128), + Rect2i(256, 448, 256, 64), +] # ── Exports ────────────────────────────────────────────────────────────────── @@ -27,6 +37,9 @@ const FACE_LOWER_POS := Vector2i(0, 384) ## Example: "res://src/resources/characters/BloodElf/Female/BloodElfFemale_textures" @export var textures_dir: String = "" : set = _set_textures_dir +## Root folder containing extracted WoW data. Used for DBC item component BLPs. +@export var extracted_dir: String = "res://data/extracted" + ## Skin colour index (selects skin_XX.png / naked_torso_XX.png / ## naked_pelvis_XX.png). @export var skin_color: int = 0 : set = _set_skin_color @@ -38,6 +51,8 @@ const FACE_LOWER_POS := Vector2i(0, 384) # MeshInstance3D surfaces to override: Array of {node, surface_idx} var _skin_surfaces: Array = [] +var _equipment_components := PackedStringArray() +var _blp_image_cache: Dictionary = {} # True once _ready has run (prevents setters from firing before init) var _ready_done := false @@ -111,7 +126,7 @@ func _scan_skin_surfaces() -> void: ## A surface is a "skin surface" if: ## - its active material is a StandardMaterial3D (not ShaderMaterial = eye glow) -## - AND its albedo texture is 512x512 (the composited body skin) +## - AND its albedo texture is square body texture, usually 256x256 or 512x512 func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool: # Prefer surface override, fall back to mesh material var mat := mesh_inst.get_surface_override_material(si) @@ -123,11 +138,10 @@ func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool: var tex := std.albedo_texture if tex == null: return false - # Skin texture is 512x512; hair/other textures are smaller var img := tex.get_image() if img == null: return false - return img.get_width() == 512 and img.get_height() == 512 + return img.get_width() == img.get_height() and img.get_width() >= 256 # ── Compositing ────────────────────────────────────────────────────────────── @@ -151,13 +165,20 @@ func _build_skin_texture() -> ImageTexture: return null # 2. Naked overlays (underwear) - _blit(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS) - _blit(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS) + _blit_scaled(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS) + _blit_scaled(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS) # 3. Face - _blit(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS) - _blit(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS) + _blit_scaled(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS) + _blit_scaled(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS) + for slot in mini(_equipment_components.size(), COMPONENT_RECTS.size()): + var rel_path := String(_equipment_components[slot]) + if rel_path.is_empty(): + continue + _blit_reference_rect(base, _load_blp_layer(rel_path), COMPONENT_RECTS[slot]) + + base.generate_mipmaps() return ImageTexture.create_from_image(base) @@ -184,10 +205,15 @@ func _apply_texture(tex: ImageTexture) -> void: func _load_layer(filename: String) -> Image: var path := textures_dir.path_join(filename) - if not FileAccess.file_exists(path): + if not ResourceLoader.exists(path) and not FileAccess.file_exists(path): return null - var img := Image.load_from_file(path) - return img + var texture := ResourceLoader.load(path, "Texture2D", ResourceLoader.CACHE_MODE_REUSE) as Texture2D + if texture != null: + var image := texture.get_image() + return image.duplicate() if image != null else null + if FileAccess.file_exists(path): + return Image.load_from_file(path) + return null func _blit(dst: Image, src: Image, pos: Vector2i) -> void: @@ -196,6 +222,58 @@ func _blit(dst: Image, src: Image, pos: Vector2i) -> void: dst.blend_rect(src, Rect2i(Vector2i.ZERO, src.get_size()), pos) +func _blit_scaled(dst: Image, src: Image, reference_pos: Vector2i) -> void: + if src == null: + return + var scale := float(dst.get_width()) / 512.0 + var target_pos := Vector2i( + int(round(float(reference_pos.x) * scale)), + int(round(float(reference_pos.y) * scale))) + if is_equal_approx(scale, 1.0): + _blit(dst, src, target_pos) + return + var target_size := Vector2i( + maxi(1, int(round(float(src.get_width()) * scale))), + maxi(1, int(round(float(src.get_height()) * scale)))) + var scaled := src.duplicate() + scaled.resize(target_size.x, target_size.y, Image.INTERPOLATE_LANCZOS) + _blit(dst, scaled, target_pos) + + +func _blit_reference_rect(dst: Image, src: Image, reference_rect: Rect2i) -> void: + if src == null: + return + var scale_x := float(dst.get_width()) / 512.0 + var scale_y := float(dst.get_height()) / 512.0 + var target_rect := Rect2i( + Vector2i( + int(round(float(reference_rect.position.x) * scale_x)), + int(round(float(reference_rect.position.y) * scale_y))), + Vector2i( + maxi(1, int(round(float(reference_rect.size.x) * scale_x))), + maxi(1, int(round(float(reference_rect.size.y) * scale_y))))) + var scaled := src.duplicate() + scaled.resize(target_rect.size.x, target_rect.size.y, Image.INTERPOLATE_LANCZOS) + _blit(dst, scaled, target_rect.position) + + +func _load_blp_layer(rel_path: String) -> Image: + if rel_path.is_empty(): + return null + if _blp_image_cache.has(rel_path): + return (_blp_image_cache[rel_path] as Image).duplicate() if _blp_image_cache[rel_path] != null else null + if not ClassDB.class_exists("BLPLoader"): + _blp_image_cache[rel_path] = null + return null + var abs_path := ProjectSettings.globalize_path(extracted_dir.trim_suffix("/").path_join(rel_path.replace("\\", "/"))) + if not FileAccess.file_exists(abs_path): + _blp_image_cache[rel_path] = null + return null + var img: Image = ClassDB.instantiate("BLPLoader").call("load_image", abs_path) + _blp_image_cache[rel_path] = img + return img.duplicate() if img != null else null + + func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]: var result: Array[MeshInstance3D] = [] _collect_meshes(root, result) @@ -217,6 +295,11 @@ func refresh() -> void: _recomposite() +func set_equipment_components(components: PackedStringArray) -> void: + _equipment_components = components + _recomposite() + + ## Returns the number of skin surfaces found (useful for debugging). func get_skin_surface_count() -> int: return _skin_surfaces.size() diff --git a/src/scenes/character/wow_character_outfit_resolver.gd b/src/scenes/character/wow_character_outfit_resolver.gd new file mode 100644 index 0000000..24d5a32 --- /dev/null +++ b/src/scenes/character/wow_character_outfit_resolver.gd @@ -0,0 +1,252 @@ +extends RefCounted + +const SLOT_UPPER_ARM := 0 +const SLOT_LOWER_ARM := 1 +const SLOT_HAND := 2 +const SLOT_UPPER_TORSO := 3 +const SLOT_LOWER_TORSO := 4 +const SLOT_UPPER_LEG := 5 +const SLOT_LOWER_LEG := 6 +const SLOT_FOOT := 7 +const SLOT_COUNT := 8 + +const COMPONENT_FOLDERS := [ + "ArmUpperTexture", + "ArmLowerTexture", + "HandTexture", + "TorsoUpperTexture", + "TorsoLowerTexture", + "LegUpperTexture", + "LegLowerTexture", + "FootTexture", +] + +const RACE_IDS := { + "human": 1, + "orc": 2, + "dwarf": 3, + "nightelf": 4, + "scourge": 5, + "undead": 5, + "tauren": 6, + "gnome": 7, + "troll": 8, + "bloodelf": 10, + "draenei": 11, +} + +var extracted_dir := "res://data/extracted" +var _char_start := {} +var _item_display := {} +var _item_display_by_id := {} +var _texture_cache := {} + + +func load_dbcs(p_extracted_dir: String) -> bool: + extracted_dir = p_extracted_dir.trim_suffix("/") + var base := extracted_dir.path_join("DBFilesClient") + _char_start = _load_wdbc(base.path_join("CharStartOutfit.dbc")) + _item_display = _load_wdbc(base.path_join("ItemDisplayInfo.dbc")) + _index_item_display() + return not _char_start.is_empty() and not _item_display.is_empty() + + +func resolve_start_outfit(race_name: String, gender_name: String, class_id: int) -> Dictionary: + if _char_start.is_empty() or _item_display.is_empty(): + return {} + var race_id := race_id_for_name(race_name) + var gender_id := gender_id_for_name(gender_name) + if race_id <= 0 or gender_id < 0: + return {} + + var wanted_key := race_id | ((class_id & 0xff) << 8) | ((gender_id & 0xff) << 16) + for i in int(_char_start["records"]): + if int(_dbc_u32(_char_start, i, 1)) != wanted_key: + continue + return _resolve_start_record(i, gender_id) + for i in int(_char_start["records"]): + var race_class_gender := int(_dbc_u32(_char_start, i, 1)) + if (race_class_gender & 0xff) == race_id and ((race_class_gender >> 16) & 0xff) == gender_id: + return _resolve_start_record(i, gender_id) + return {} + + +func _resolve_start_record(record: int, gender_id: int) -> Dictionary: + var display_start := _char_start_display_field() + var display_count := mini(24, int(_char_start["fields"]) - display_start) + var display_ids := PackedInt32Array() + for n in display_count: + var display_id := int(_dbc_u32(_char_start, record, display_start + n)) + if display_id > 0 and display_id != 0xffffffff: + display_ids.append(display_id) + var outfit := resolve_display_ids(display_ids, gender_id) + outfit["source_record"] = record + outfit["source_class"] = (int(_dbc_u32(_char_start, record, 1)) >> 8) & 0xff + return outfit + + +func resolve_display_ids(display_ids: PackedInt32Array, gender_id: int) -> Dictionary: + var textures := PackedStringArray() + textures.resize(SLOT_COUNT) + var geosets := PackedInt32Array() + geosets.resize(3) + var flags := 0 + var used_display_ids := PackedInt32Array() + + var texture_base := _item_display_texture_base() + var geoset_base := _item_display_geoset_base() + var flags_field := _item_display_flags_field() + for display_id in display_ids: + var record := int(_item_display_by_id.get(display_id, -1)) + if record < 0: + continue + used_display_ids.append(display_id) + for i in 3: + var geoset_group := int(_dbc_u32(_item_display, record, geoset_base + i)) + if geoset_group != 0: + geosets[i] = geoset_group + flags |= int(_dbc_u32(_item_display, record, flags_field)) + for slot in SLOT_COUNT: + var stem := _dbc_string(_item_display, record, texture_base + slot) + if stem.is_empty(): + continue + var rel_path := component_texture_path(stem, slot, gender_id) + if not rel_path.is_empty(): + textures[slot] = rel_path + + return { + "display_ids": used_display_ids, + "textures": textures, + "geosets": geosets, + "flags": flags, + } + + +func component_texture_path(stem: String, slot: int, gender_id: int) -> String: + if stem.is_empty() or slot < 0 or slot >= SLOT_COUNT: + return "" + var suffix := "F" if gender_id == 1 else "M" + var folder := String(COMPONENT_FOLDERS[slot]) + var candidates := [ + "ITEM/TEXTURECOMPONENTS/%s/%s_%s.blp" % [folder, stem, suffix], + "Item/TextureComponents/%s/%s_%s.blp" % [folder, stem, suffix], + "ITEM/TEXTURECOMPONENTS/%s/%s_U.blp" % [folder, stem], + "Item/TextureComponents/%s/%s_U.blp" % [folder, stem], + ] + for rel_path in candidates: + if _texture_exists(rel_path): + return rel_path + return candidates[0] + + +func race_id_for_name(race_name: String) -> int: + var normalized := race_name.replace("_", "").replace(" ", "").to_lower() + return int(RACE_IDS.get(normalized, 0)) + + +func gender_id_for_name(gender_name: String) -> int: + var normalized := gender_name.to_lower() + if normalized == "male" or normalized == "m": + return 0 + if normalized == "female" or normalized == "f": + return 1 + return -1 + + +func infer_race_gender_from_model_path(model_path: String) -> Dictionary: + var parts := model_path.replace("\\", "/").split("/", false) + for i in range(parts.size() - 2): + if String(parts[i]).to_lower() == "characters": + return {"race": String(parts[i + 1]), "gender": String(parts[i + 2])} + return {} + + +func _index_item_display() -> void: + _item_display_by_id.clear() + if _item_display.is_empty(): + return + for i in int(_item_display["records"]): + var display_id := int(_dbc_u32(_item_display, i, 0)) + if display_id > 0: + _item_display_by_id[display_id] = i + + +func _char_start_display_field() -> int: + if int(_char_start.get("fields", 0)) >= 77: + return 26 + return 14 + + +func _item_display_texture_base() -> int: + return 15 if int(_item_display.get("fields", 0)) >= 25 else 14 + + +func _item_display_geoset_base() -> int: + return 7 if int(_item_display.get("fields", 0)) >= 25 else 6 + + +func _item_display_flags_field() -> int: + return 10 if int(_item_display.get("fields", 0)) >= 25 else 9 + + +func _texture_exists(rel_path: String) -> bool: + if _texture_cache.has(rel_path): + return bool(_texture_cache[rel_path]) + var exists := FileAccess.file_exists(ProjectSettings.globalize_path(extracted_dir.path_join(rel_path))) + _texture_cache[rel_path] = exists + return exists + + +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_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() diff --git a/src/scenes/character/wow_character_outfit_resolver.gd.uid b/src/scenes/character/wow_character_outfit_resolver.gd.uid new file mode 100644 index 0000000..15eb20d --- /dev/null +++ b/src/scenes/character/wow_character_outfit_resolver.gd.uid @@ -0,0 +1 @@ +uid://d3e8tcysnsliv diff --git a/src/scenes/player/third_person_wow_controller.gd b/src/scenes/player/third_person_wow_controller.gd index 8d3e1ce..a2fff6a 100644 --- a/src/scenes/player/third_person_wow_controller.gd +++ b/src/scenes/player/third_person_wow_controller.gd @@ -1,5 +1,9 @@ extends CharacterBody3D +const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd") +const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd") +const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd") + const TILE_SIZE := 533.33333 const CHUNK_SIZE := TILE_SIZE / 16.0 const UNIT_SIZE := CHUNK_SIZE / 8.0 @@ -14,26 +18,39 @@ const UNIT_SIZE := CHUNK_SIZE / 8.0 @export var backward_speed: float = 4.5 @export var strafe_speed: float = 4.5 @export var sprint_multiplier: float = 6.0 +@export var flight_vertical_speed: float = 7.0 @export var acceleration: float = 28.0 @export var mouse_sensitivity: float = 0.003 @export var camera_pitch_min: float = deg_to_rad(-65.0) @export var camera_pitch_max: float = deg_to_rad(35.0) @export var camera_height: float = 1.7 @export var camera_distance: float = 8.0 +@export var camera_min_distance: float = 2.0 +@export var camera_max_distance: float = 18.0 +@export var camera_zoom_step: float = 1.0 @export var ground_offset: float = 0.05 @export var ground_snap_speed: float = 24.0 @export var camera_pivot_path: NodePath = NodePath("CameraPivot") @export var camera_path: NodePath = NodePath("CameraPivot/Camera3D") @export var visual_path: NodePath = NodePath("Visual") +@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb" +@export var character_class_id: int = 1 +@export var character_scale: float = 1.0 +@export var character_yaw_offset_degrees: float = 90.0 +@export var animation_blend_time: float = 0.15 var _camera_pivot: Node3D var _camera: Camera3D var _visual: Node3D +var _character_root: Node3D +var _animation_player: AnimationPlayer +var _active_animation := "" var _captured := false var _yaw := 0.0 var _pitch := deg_to_rad(-18.0) var _horizontal_velocity := Vector3.ZERO +var _flight_enabled := false var _adt_cache: Dictionary = {} @@ -47,6 +64,7 @@ func _ready() -> void: if spawn_at_tile_center: global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE global_position.z = (float(spawn_tile_y) + 0.5) * TILE_SIZE + _load_character_visual() var ground := _sample_ground_height(global_position) if is_finite(ground): global_position.y = ground + ground_offset @@ -58,9 +76,18 @@ func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: _captured = event.pressed Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE + elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP: + camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance) + _apply_camera_transform() + elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance) + _apply_camera_transform() elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: _captured = false Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_SPACE: + _flight_enabled = not _flight_enabled + _horizontal_velocity = Vector3.ZERO if _captured and event is InputEventMouseMotion: _yaw -= event.relative.x * mouse_sensitivity @@ -74,18 +101,24 @@ func _physics_process(delta: float) -> void: var target_velocity := Vector3.ZERO if input_dir.length_squared() > 0.0: target_velocity = _movement_vector(input_dir) + if _flight_enabled: + target_velocity.y += _flight_vertical_velocity() _horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta) global_position += _horizontal_velocity * delta - var ground := _sample_ground_height(global_position) - if is_finite(ground): - var target_y := ground + ground_offset - global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0)) + if not _flight_enabled: + var ground := _sample_ground_height(global_position) + if is_finite(ground): + var target_y := ground + ground_offset + global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0)) - if _visual and _horizontal_velocity.length_squared() > 0.01: + var horizontal_motion := Vector2(_horizontal_velocity.x, _horizontal_velocity.z) + if _visual and horizontal_motion.length_squared() > 0.01: _visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z) + _update_character_animation(horizontal_motion.length_squared() > 0.04) + _apply_camera_transform() @@ -105,8 +138,12 @@ func _get_input_dir() -> Vector2: func _movement_vector(input_dir: Vector2) -> Vector3: var forward := -global_basis.z var right := global_basis.x - forward.y = 0.0 - right.y = 0.0 + if _flight_enabled and _camera_pivot: + forward = -_camera_pivot.global_basis.z + right = _camera_pivot.global_basis.x + else: + forward.y = 0.0 + right.y = 0.0 forward = forward.normalized() right = right.normalized() @@ -116,6 +153,167 @@ func _movement_vector(input_dir: Vector2) -> Vector3: return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint +func _flight_vertical_velocity() -> float: + var dir := 0.0 + if Input.is_key_pressed(KEY_E): + dir += 1.0 + if Input.is_key_pressed(KEY_Q): + dir -= 1.0 + var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0 + return dir * flight_vertical_speed * sprint + + +func _load_character_visual() -> void: + if character_model_path.is_empty() or _visual == null: + return + if _visual is MeshInstance3D: + (_visual as MeshInstance3D).mesh = null + _visual.position = Vector3.ZERO + + if _character_root and is_instance_valid(_character_root): + _character_root.queue_free() + _character_root = null + _animation_player = null + _active_animation = "" + + var scene: PackedScene = load(character_model_path) + if scene == null: + push_warning("ThirdPersonWowController: cannot load character model: %s" % character_model_path) + return + + _character_root = Node3D.new() + _character_root.name = "CharacterModel" + _character_root.set_script(GEOSET_CONTROLLER_SCRIPT) + _character_root.scale = Vector3.ONE * maxf(character_scale, 0.001) + _character_root.rotation_degrees.y = character_yaw_offset_degrees + + var instance := scene.instantiate() + instance.name = character_model_path.get_file().get_basename() + _character_root.add_child(instance) + + var compositor := Node.new() + compositor.name = "CharacterTextureCompositor" + compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT) + compositor.set("textures_dir", _character_textures_dir(character_model_path)) + compositor.set("extracted_dir", extracted_dir) + _character_root.add_child(compositor) + + _visual.add_child(_character_root) + await get_tree().process_frame + _fit_character_to_ground() + _apply_character_starter_outfit(compositor) + _animation_player = _find_animation_player(_character_root) + if _animation_player: + _prepare_animation_player(_animation_player) + _update_character_animation(false) + + +func _fit_character_to_ground() -> void: + if _character_root == null: + return + var aabb := _calculate_aabb(_character_root) + if aabb.size == Vector3.ZERO: + return + var local_min_y := aabb.position.y - _character_root.global_position.y + _character_root.position.y -= local_min_y + + +func _apply_character_starter_outfit(compositor: Node) -> void: + if _character_root == null or compositor == null: + return + var resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new() + if not resolver.call("load_dbcs", extracted_dir): + return + var inferred: Dictionary = resolver.call("infer_race_gender_from_model_path", character_model_path) + var race := String(inferred.get("race", "Human")) + var gender := String(inferred.get("gender", "Male")) + var outfit: Dictionary = resolver.call("resolve_start_outfit", race, gender, character_class_id) + if outfit.is_empty(): + return + if compositor.has_method("set_equipment_components"): + compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray())) + if _character_root.has_method("apply_outfit_defaults"): + _character_root.call("apply_outfit_defaults", outfit) + + +func _update_character_animation(is_moving: bool) -> void: + if _animation_player == null: + return + var wanted := _choose_animation(["Run", "Walk"]) if is_moving else _choose_animation(["Stand", "Idle"]) + if wanted.is_empty() or wanted == _active_animation: + return + _active_animation = wanted + _animation_player.play(wanted, animation_blend_time) + + +func _prepare_animation_player(player: AnimationPlayer) -> void: + player.playback_default_blend_time = animation_blend_time + for animation_name in player.get_animation_list(): + var lower := String(animation_name).to_lower() + if lower == "stand" or lower == "idle" or lower == "run" or lower == "walk" or lower.contains("stand") or lower.contains("run") or lower.contains("walk"): + var animation := player.get_animation(animation_name) + if animation: + animation.loop_mode = Animation.LOOP_LINEAR + + +func _choose_animation(candidates: Array[String]) -> String: + if _animation_player == null: + return "" + for candidate in candidates: + if _animation_player.has_animation(candidate): + return candidate + var list := _animation_player.get_animation_list() + for candidate in candidates: + var lower := candidate.to_lower() + for animation_name in list: + if String(animation_name).to_lower().contains(lower): + return String(animation_name) + return "" + + +func _find_animation_player(root: Node) -> AnimationPlayer: + if root is AnimationPlayer: + return root + for child in root.get_children(): + var found := _find_animation_player(child) + if found: + return found + return null + + +func _calculate_aabb(root: Node) -> AABB: + var result := AABB() + var has_aabb := false + for mesh_inst in _iter_mesh_instances(root): + if mesh_inst.mesh == null: + continue + var local_aabb := mesh_inst.mesh.get_aabb() + var global_aabb := mesh_inst.global_transform * local_aabb + if not has_aabb: + result = global_aabb + has_aabb = true + else: + result = result.merge(global_aabb) + return result if has_aabb else AABB() + + +func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]: + var result: Array[MeshInstance3D] = [] + _collect_mesh_instances(root, result) + return result + + +func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void: + if node is MeshInstance3D: + result.append(node) + for child in node.get_children(): + _collect_mesh_instances(child, result) + + +func _character_textures_dir(model_path: String) -> String: + return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures") + + func _apply_camera_transform() -> void: if _camera_pivot: _camera_pivot.position = Vector3(0.0, camera_height, 0.0) diff --git a/src/scenes/sky/wow_sky_controller.gd b/src/scenes/sky/wow_sky_controller.gd index e1a0a89..f21de8f 100644 --- a/src/scenes/sky/wow_sky_controller.gd +++ b/src/scenes/sky/wow_sky_controller.gd @@ -95,11 +95,13 @@ var _active_skybox_node: Node3D var _last_logged_area_id := -1 var _last_logged_zone_id := -1 var _last_logged_profile_signature := "" +var _last_global_light_signature := "" func _ready() -> void: _resolve_nodes() _prepare_environment() + _ensure_wow_shader_globals() _loaded = _load_lighting_dbcs() if _loaded: print("WowSkyController: loaded %d LightParams profiles, %d area records and %d %s light volumes" % [ @@ -271,6 +273,43 @@ func _apply_sky(delta: float) -> void: _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) + _update_wow_shader_globals(sun_elevation) + + +func _ensure_wow_shader_globals() -> void: + pass + + +func _update_wow_shader_globals(sun_elevation: float) -> void: + if not _environment: + return + var light_dir := Vector3(-0.35, 0.82, -0.45).normalized() + if _sun: + light_dir = -_sun.global_transform.basis.z.normalized() + var fog_range := Vector2(_environment.fog_depth_begin, _environment.fog_depth_end) + var raw_light: Color = _sun.light_color if _sun else Color(1.0, 0.91, 0.78, 1.0) + var shader_ambient := _world_shader_color(_environment.ambient_light_color, 0.65, Color(0.72, 0.70, 0.64, 1.0), 0.22) + var shader_light := _world_shader_color(raw_light, 0.45, Color(1.0, 0.92, 0.78, 1.0), 0.16) + var shader_fog := _world_shader_color(_environment.fog_light_color, 0.75, Color(0.62, 0.66, 0.66, 1.0), 0.38) + var density: float = clampf(_environment.fog_density * 2200.0, 0.0, 0.42) + var signature := "%s|%s|%s|%s|%.4f|%.3f" % [ + str(shader_ambient), + str(shader_light), + str(light_dir.snapped(Vector3(0.001, 0.001, 0.001))), + str(fog_range.snapped(Vector2(1.0, 1.0))), + density, + sun_elevation] + if signature == _last_global_light_signature: + return + _last_global_light_signature = signature + RenderingServer.global_shader_parameter_set(&"wow_ambient_color", shader_ambient) + RenderingServer.global_shader_parameter_set(&"wow_light_color", shader_light) + RenderingServer.global_shader_parameter_set(&"wow_light_dir", light_dir) + RenderingServer.global_shader_parameter_set(&"wow_fog_color", shader_fog) + RenderingServer.global_shader_parameter_set(&"wow_fog_range", fog_range) + RenderingServer.global_shader_parameter_set(&"wow_fog_density", density) + RenderingServer.global_shader_parameter_set(&"wow_sun_elevation", sun_elevation) + func _load_lighting_dbcs() -> bool: var base := _res_path(extracted_dir).path_join("DBFilesClient") @@ -860,6 +899,18 @@ func _color_luma(color: Color) -> float: return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722 +func _world_shader_color(color: Color, desaturate: float, neutral: Color, neutral_mix: float) -> Color: + var luma := clampf(_color_luma(color), 0.0, 1.25) + var gray := Color(luma, luma, luma, color.a) + var result := color.lerp(gray, clampf(desaturate, 0.0, 1.0)) + result = result.lerp(neutral, clampf(neutral_mix, 0.0, 1.0)) + return Color( + clampf(result.r, 0.0, 1.25), + clampf(result.g, 0.0, 1.25), + clampf(result.b, 0.0, 1.25), + color.a) + + func _get_light_volume_count(id: int) -> int: return int((_light_volumes_by_map.get(id, []) as Array).size()) diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index 78f9a6e..78f214b 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -61,6 +61,8 @@ max_concurrent_tile_tasks = 1 tile_lod_remove_ops_per_tick = 1 m2_build_groups_per_tick = 1 m2_multimesh_batch_size = 64 +m2_animated_denylist_patterns = PackedStringArray("gryphonroost") +m2_animated_allowlist_patterns = PackedStringArray("creature/fish/", "creature/eagle/", "world/critter/") wmo_render_group_ops_per_tick = 16 cached_tile_mesh_limit = 48 terrain_quality_mesh_cache_limit = 48 @@ -85,13 +87,14 @@ m2_tile_radius = 3 wmo_tile_radius = 5 m2_visibility_range = 1600.0 wmo_visibility_range = 3600.0 +debug_streaming = true 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) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) script = ExtResource("2_player") spawn_tile_x = 31 -spawn_tile_y = 31 +spawn_tile_y = 49 [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) diff --git a/src/scenes/streaming/m2_native_animator.gd b/src/scenes/streaming/m2_native_animator.gd new file mode 100644 index 0000000..c205b67 --- /dev/null +++ b/src/scenes/streaming/m2_native_animator.gd @@ -0,0 +1,245 @@ +extends Node +class_name M2NativeAnimator + +var mesh_instance_path: NodePath = NodePath("../Mesh") +var mesh_instance: MeshInstance3D +var mesh: ArrayMesh +var bones: Array = [] +var surfaces: Array = [] +var animation_length: float = 0.0 +var playback_speed: float = 1.0 + +var _time: float = 0.0 +var _materials: Array[Material] = [] +var _unique_mesh_ready := false +var _prepared := false + + +func setup(target_mesh_instance: MeshInstance3D, bone_data: Array, surface_data: Array, length_seconds: float) -> void: + mesh_instance = target_mesh_instance + if mesh_instance != null: + mesh_instance_path = get_path_to(mesh_instance) + mesh = mesh_instance.mesh as ArrayMesh + bones = bone_data + surfaces = surface_data + animation_length = maxf(length_seconds, 0.0) + _capture_materials() + _make_mesh_unique() + _rebuild_mesh(0.0) + set_process(mesh != null and not bones.is_empty() and not surfaces.is_empty() and animation_length > 0.0) + + +func _ready() -> void: + prepare_runtime() + + +func _process(delta: float) -> void: + if mesh == null or bones.is_empty() or surfaces.is_empty() or animation_length <= 0.0: + return + _time = fmod(_time + delta * playback_speed, animation_length) + _rebuild_mesh(_time) + + +func set_phase(phase: float) -> void: + if animation_length <= 0.0: + _time = 0.0 + else: + _time = fposmod(animation_length * phase, animation_length) + _rebuild_mesh(_time) + + +func prepare_runtime() -> bool: + _resolve_mesh_instance() + _capture_materials() + _unique_mesh_ready = false + _make_mesh_unique() + _rebuild_mesh(_time) + _prepared = mesh != null and not bones.is_empty() and not surfaces.is_empty() and animation_length > 0.0 + set_process(_prepared) + return _prepared + + +func runtime_debug_state() -> Dictionary: + return { + "prepared": _prepared, + "processing": is_processing(), + "has_mesh": mesh != null, + "bones": bones.size(), + "surfaces": surfaces.size(), + "length": animation_length, + } + + +func _resolve_mesh_instance() -> void: + var resolved := get_node_or_null(mesh_instance_path) + if resolved is MeshInstance3D: + mesh_instance = resolved as MeshInstance3D + if mesh_instance != null: + mesh = mesh_instance.mesh as ArrayMesh + + +func _make_mesh_unique() -> void: + if _unique_mesh_ready or mesh_instance == null or mesh_instance.mesh == null: + return + var duplicated := mesh_instance.mesh.duplicate(true) as ArrayMesh + if duplicated == null: + return + mesh_instance.mesh = duplicated + mesh = duplicated + _unique_mesh_ready = true + + +func _capture_materials() -> void: + if mesh == null: + return + if not _materials.is_empty() and _materials.size() == mesh.get_surface_count(): + return + _materials.clear() + for surface_index in mesh.get_surface_count(): + _materials.append(mesh.surface_get_material(surface_index)) + + +func _rebuild_mesh(time: float) -> void: + if mesh == null: + return + var bone_matrices := _build_bone_matrices(time) + if bone_matrices.is_empty(): + return + + mesh.clear_surfaces() + for surface_index in surfaces.size(): + var source = surfaces[surface_index] + if not (source is Dictionary): + continue + var surface: Dictionary = source + var base_vertices: PackedVector3Array = surface.get("vertices", PackedVector3Array()) + var base_normals: PackedVector3Array = surface.get("normals", PackedVector3Array()) + var uvs: PackedVector2Array = surface.get("uvs", PackedVector2Array()) + var uvs2: PackedVector2Array = surface.get("uvs2", PackedVector2Array()) + var indices: PackedInt32Array = surface.get("indices", PackedInt32Array()) + var bone_indices: PackedInt32Array = surface.get("bones", PackedInt32Array()) + var weights: PackedFloat32Array = surface.get("weights", PackedFloat32Array()) + if base_vertices.is_empty() or indices.is_empty(): + continue + + var vertices := PackedVector3Array() + var normals := PackedVector3Array() + vertices.resize(base_vertices.size()) + if base_normals.size() == base_vertices.size(): + normals.resize(base_normals.size()) + + var can_skin := bone_indices.size() == base_vertices.size() * 4 and weights.size() == base_vertices.size() * 4 + for vertex_index in base_vertices.size(): + if can_skin: + var skinned_pos := Vector3.ZERO + var skinned_nrm := Vector3.ZERO + var total_weight := 0.0 + for influence in 4: + var weight := weights[vertex_index * 4 + influence] + if weight <= 0.0: + continue + var bone_index := bone_indices[vertex_index * 4 + influence] + if bone_index < 0 or bone_index >= bone_matrices.size(): + continue + var transform: Transform3D = bone_matrices[bone_index] + skinned_pos += transform * base_vertices[vertex_index] * weight + if normals.size() == base_normals.size(): + skinned_nrm += (transform.basis * base_normals[vertex_index]) * weight + total_weight += weight + if total_weight > 0.0: + vertices[vertex_index] = skinned_pos / total_weight + if normals.size() == base_normals.size(): + normals[vertex_index] = (skinned_nrm / total_weight).normalized() + else: + vertices[vertex_index] = base_vertices[vertex_index] + if normals.size() == base_normals.size(): + normals[vertex_index] = base_normals[vertex_index] + else: + vertices[vertex_index] = base_vertices[vertex_index] + if normals.size() == base_normals.size(): + normals[vertex_index] = base_normals[vertex_index] + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + if normals.size() == vertices.size(): + arrays[Mesh.ARRAY_NORMAL] = normals + if uvs.size() == vertices.size(): + arrays[Mesh.ARRAY_TEX_UV] = uvs + if uvs2.size() == vertices.size(): + arrays[Mesh.ARRAY_TEX_UV2] = uvs2 + arrays[Mesh.ARRAY_INDEX] = indices + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + if surface_index < _materials.size() and _materials[surface_index] != null: + mesh.surface_set_material(mesh.get_surface_count() - 1, _materials[surface_index]) + + +func _build_bone_matrices(time: float) -> Array[Transform3D]: + var result: Array[Transform3D] = [] + result.resize(bones.size()) + for i in range(bones.size()): + var bone: Dictionary = bones[i] + var pivot: Vector3 = bone.get("pivot", Vector3.ZERO) + var translation := _sample_vec3_track(bone.get("translation", {}), time, Vector3.ZERO) + var rotation := _sample_quat_track(bone.get("rotation", {}), time, Quaternion.IDENTITY) + var scale := _sample_vec3_track(bone.get("scale", {}), time, Vector3.ONE) + + var local := Transform3D(Basis.IDENTITY, pivot + translation) + local = local * Transform3D(Basis(rotation).scaled(scale), Vector3.ZERO) + local = local * Transform3D(Basis.IDENTITY, -pivot) + + var parent := int(bone.get("parent", -1)) + if parent >= 0 and parent < i: + result[i] = result[parent] * local + else: + result[i] = local + return result + + +func _sample_vec3_track(track: Variant, time: float, fallback: Vector3) -> Vector3: + if not (track is Dictionary): + return fallback + var times: PackedFloat32Array = track.get("times", PackedFloat32Array()) + var values: PackedVector3Array = track.get("values", PackedVector3Array()) + var index := _track_index(times, time) + if index < 0 or values.is_empty(): + return fallback + if index >= values.size() - 1 or index >= times.size() - 1: + return values[mini(index, values.size() - 1)] + var t0 := times[index] + var t1 := times[index + 1] + var alpha := 0.0 if is_equal_approx(t0, t1) else clampf((time - t0) / (t1 - t0), 0.0, 1.0) + return values[index].lerp(values[index + 1], alpha) + + +func _sample_quat_track(track: Variant, time: float, fallback: Quaternion) -> Quaternion: + if not (track is Dictionary): + return fallback + var times: PackedFloat32Array = track.get("times", PackedFloat32Array()) + var raw_values: PackedVector4Array = track.get("values", PackedVector4Array()) + var index := _track_index(times, time) + if index < 0 or raw_values.is_empty(): + return fallback + var q0 := _quat_from_vec4(raw_values[mini(index, raw_values.size() - 1)]) + if index >= raw_values.size() - 1 or index >= times.size() - 1: + return q0.normalized() + var q1 := _quat_from_vec4(raw_values[index + 1]) + var t0 := times[index] + var t1 := times[index + 1] + var alpha := 0.0 if is_equal_approx(t0, t1) else clampf((time - t0) / (t1 - t0), 0.0, 1.0) + return q0.slerp(q1, alpha).normalized() + + +func _track_index(times: PackedFloat32Array, time: float) -> int: + if times.is_empty(): + return -1 + if times.size() == 1 or time <= times[0]: + return 0 + for i in range(times.size() - 1): + if time >= times[i] and time < times[i + 1]: + return i + return times.size() - 1 + + +func _quat_from_vec4(v: Vector4) -> Quaternion: + return Quaternion(v.x, v.y, v.z, v.w).normalized() diff --git a/src/scenes/streaming/m2_native_animator.gd.uid b/src/scenes/streaming/m2_native_animator.gd.uid new file mode 100644 index 0000000..4a628ac --- /dev/null +++ b/src/scenes/streaming/m2_native_animator.gd.uid @@ -0,0 +1 @@ +uid://b8pxshlr85g2t diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index de107c3..fae7284 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -10,13 +10,17 @@ const CONTROL_SPLAT_TILE_SCRIPT := preload("res://src/resources/control_splat_ad const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd") const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd") const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") +const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") +const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") const REQUIRED_BAKED_TILE_FORMAT_VERSION := 4 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 +const WMO_MATERIAL_REFRESH_VERSION := 3 const TILE_SIZE := 533.33333 const CHUNK_SIZE := TILE_SIZE / 16.0 -const M2_RIGHT_YAW_OFFSET := 0.0 +const ADT_CLIFFROCK_WORLD_YAW_OFFSET := PI +const ADT_WATERFALL_WORLD_YAW_OFFSET := PI * 0.5 const QUALITY_CUSTOM := "Custom" const QUALITY_PERFORMANCE := "Performance" const QUALITY_BALANCED := "Balanced" @@ -51,6 +55,22 @@ const QUALITY_HIGH := "High" @export var m2_build_groups_per_tick: int = 2 ## Maximum instances written into a single M2 MultiMesh per frame. @export var m2_multimesh_batch_size: int = 128 +## Experimental GLB M2 animation is allowed only for simple one-primitive creatures. +## Complex doodads (for example GryphonRoost) need a native M2 animator because GLB +## conversion currently breaks their skin/material fidelity. +@export var enable_m2_animated_instances: bool = true +## Reject complex animated M2 GLBs until the skinning converter is robust for composite doodads. +@export var m2_animated_max_primitives: int = 3 +@export var m2_animated_denylist_patterns: PackedStringArray = PackedStringArray(["gryphonroost"]) +@export var m2_animated_allowlist_patterns: PackedStringArray = PackedStringArray([ + "creature/fish/", + "creature/eagle/", + "world/critter/", +]) +## Maximum animated M2 scene instances attached per build step. Keep low to avoid hitches. +@export var m2_animated_instances_per_tick: int = 8 +## Loaded animated M2 scene resources finalized per frame. +@export var m2_animation_finalize_ops_per_tick: int = 1 ## Loaded M2 scene resources converted to meshes per frame. @export var m2_mesh_finalize_ops_per_tick: int = 1 ## WMO instances attached per frame. @@ -165,6 +185,8 @@ var _m2_build_queue: Array = [] var _m2_mesh_cache: Dictionary = {} var _m2_mesh_load_requests: Dictionary = {} var _m2_mesh_finalize_queue: Array = [] +var _m2_animation_load_requests: Dictionary = {} +var _m2_animation_finalize_queue: Array = [] var _wmo_build_jobs: Dictionary = {} var _wmo_build_queue: Array = [] var _tile_loading_tasks: Dictionary = {} @@ -203,13 +225,17 @@ var _terrain_quality_mesh_cache: Dictionary = {} var _terrain_quality_mesh_cache_order: Array = [] var _wmo_prototype_cache: Dictionary = {} var _wmo_render_cache: Dictionary = {} +var _wmo_render_missing_cache: Dictionary = {} var _wmo_render_load_requests: Dictionary = {} var _wmo_render_build_jobs: Dictionary = {} var _wmo_render_build_queue: Array = [] var _wmo_scene_resource_cache: Dictionary = {} +var _wmo_scene_cache_missing: Dictionary = {} var _wmo_scene_load_requests: Dictionary = {} var _wmo_missing_cache: Dictionary = {} var _m2_scene_cache: Dictionary = {} +var _m2_animated_scene_cache: Dictionary = {} +var _m2_static_animation_cache: Dictionary = {} var _m2_missing_cache: Dictionary = {} var _world_wmo_root: Node3D var _wmo_registry: Dictionary = {} @@ -304,6 +330,9 @@ func _process(delta: float) -> void: _drain_m2_group_results() _profile_section(timings, "m2results", section_start, profile_enabled) section_start = Time.get_ticks_usec() if profile_enabled else 0 + _drain_m2_animation_loads() + _profile_section(timings, "m2anim", section_start, profile_enabled) + section_start = Time.get_ticks_usec() if profile_enabled else 0 _drain_m2_mesh_loads() _profile_section(timings, "m2mesh", section_start, profile_enabled) section_start = Time.get_ticks_usec() if profile_enabled else 0 @@ -359,6 +388,7 @@ func _apply_quality_preset() -> void: detail_asset_ops_per_tick = 1 m2_build_groups_per_tick = 1 m2_multimesh_batch_size = 32 + m2_animation_finalize_ops_per_tick = 1 m2_mesh_finalize_ops_per_tick = 1 wmo_build_instances_per_tick = 1 wmo_render_group_ops_per_tick = 16 @@ -409,6 +439,7 @@ func _apply_quality_preset() -> void: detail_asset_ops_per_tick = 1 m2_build_groups_per_tick = 1 m2_multimesh_batch_size = 32 + m2_animation_finalize_ops_per_tick = 1 m2_mesh_finalize_ops_per_tick = 1 wmo_build_instances_per_tick = 1 wmo_render_group_ops_per_tick = 20 @@ -459,6 +490,7 @@ func _apply_quality_preset() -> void: detail_asset_ops_per_tick = 1 m2_build_groups_per_tick = 1 m2_multimesh_batch_size = 64 + m2_animation_finalize_ops_per_tick = 1 m2_mesh_finalize_ops_per_tick = 1 wmo_build_instances_per_tick = 1 wmo_render_group_ops_per_tick = 16 @@ -884,7 +916,7 @@ func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bo var total_ms := float(Time.get_ticks_usec() - start_usec) / 1000.0 if total_ms < maxf(1.0, hitch_profiler_threshold_ms): return - print("HITCH %.2fms refresh=%s queues[tile=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d water=%d detail=%d m2task=%d m2mesh=%d m2build=%d wmobuild=%d wmogroups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] %s" % [ + print("HITCH %.2fms refresh=%s queues[tile=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d water=%d detail=%d m2task=%d m2anim=%d m2mesh=%d m2build=%d wmobuild=%d wmogroups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] %s" % [ total_ms, str(did_refresh), _tile_load_queue.size(), @@ -895,6 +927,7 @@ func _log_hitch_profile(start_usec: int, timings: Array[String], did_refresh: bo _water_load_queue.size() + _water_load_tasks.size(), _detail_asset_queue.size(), _m2_group_tasks.size(), + _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(), _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), _m2_build_queue.size(), _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), @@ -983,7 +1016,7 @@ func _tick_runtime_stats(delta: float) -> void: m2_multimeshes += int(m2_counts["multimeshes"]) m2_instances += int(m2_counts["instances"]) - print("PERF fps=%.1f tiles=%d wanted=%d retained=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d water=%d src[baked=%d ctl=%d splat=%d] cache=%d lod_rid=%d lod_node=%d lod_hidden=%d lod_prepared=%d chunk_rid=%d chunk_node=%d queues[tile=%d detail=%d m2_task=%d m2_mesh=%d m2_build=%d wmo_build=%d wmo_groups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] m2_tiles=%d m2_mm=%d m2_inst=%d wmo=%d" % [ + print("PERF fps=%.1f tiles=%d wanted=%d retained=%d loading=%d terrainup=%d terrainctl=%d terrainsplat=%d water=%d src[baked=%d ctl=%d splat=%d] cache=%d lod_rid=%d lod_node=%d lod_hidden=%d lod_prepared=%d chunk_rid=%d chunk_node=%d queues[tile=%d detail=%d m2_task=%d m2_anim=%d m2_mesh=%d m2_build=%d wmo_build=%d wmo_groups=%d lod+=%d lod-=%d chunk+=%d chunk-=%d] m2_tiles=%d m2_mm=%d m2_inst=%d wmo=%d" % [ Engine.get_frames_per_second(), _tile_states.size(), wanted_tiles, @@ -1006,6 +1039,7 @@ func _tick_runtime_stats(delta: float) -> void: _tile_load_queue.size(), _detail_asset_queue.size(), _m2_group_tasks.size(), + _m2_animation_finalize_queue.size() + _m2_animation_load_requests.size(), _m2_mesh_finalize_queue.size() + _m2_mesh_load_requests.size(), _m2_build_queue.size(), _wmo_build_queue.size() + _wmo_scene_load_requests.size() + _wmo_render_load_requests.size(), @@ -2032,6 +2066,16 @@ func _wait_for_tile_tasks() -> void: _m2_mesh_load_requests.clear() _m2_mesh_finalize_queue.clear() + for pending in _m2_animation_load_requests.values(): + var path: String = String(pending.get("path", "")) + if path.is_empty(): + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status == ResourceLoader.THREAD_LOAD_LOADED: + ResourceLoader.load_threaded_get(path) + _m2_animation_load_requests.clear() + _m2_animation_finalize_queue.clear() + for path_variant in _wmo_scene_load_requests.values(): var path := String(path_variant) if path.is_empty(): @@ -2049,6 +2093,8 @@ func _wait_for_tile_tasks() -> void: if status == ResourceLoader.THREAD_LOAD_LOADED: ResourceLoader.load_threaded_get(path) _wmo_render_load_requests.clear() + _wmo_render_missing_cache.clear() + _wmo_scene_cache_missing.clear() _wmo_render_build_jobs.clear() _wmo_render_build_queue.clear() @@ -2663,10 +2709,14 @@ func _clear_streamed_world() -> void: _m2_build_queue.clear() _m2_mesh_load_requests.clear() _m2_mesh_finalize_queue.clear() + _m2_animation_load_requests.clear() + _m2_animation_finalize_queue.clear() _wmo_build_jobs.clear() _wmo_build_queue.clear() _wmo_scene_load_requests.clear() _wmo_render_load_requests.clear() + _wmo_render_missing_cache.clear() + _wmo_scene_cache_missing.clear() _wmo_render_build_jobs.clear() _wmo_render_build_queue.clear() @@ -3256,16 +3306,27 @@ func _process_wmo_build_jobs() -> void: else: var scene := _get_wmo_scene_or_request(rel_path) if scene == null: - if not _wmo_missing_cache.has(normalized_rel): + if _wmo_scene_load_requests.has(normalized_rel): advance_job = false + else: + var live_instance := _instantiate_wmo_world(rel_path, placement) + if live_instance != null: + _prepare_runtime_wmo_instance(live_instance) + _world_wmo_root.add_child(live_instance) + _set_editor_owner_recursive(live_instance) + _wmo_registry[unique_key] = { + "node": live_instance, + "refs": {tile_key: true}, + } + refs.append(unique_key) else: - var instance := _instantiate_wmo_scene(rel_path, scene, placement) - if instance != null: - _prepare_runtime_wmo_instance(instance) - _world_wmo_root.add_child(instance) - _set_editor_owner_recursive(instance) + var scene_instance := _instantiate_wmo_scene(rel_path, scene, placement) + if scene_instance != null: + _prepare_runtime_wmo_instance(scene_instance) + _world_wmo_root.add_child(scene_instance) + _set_editor_owner_recursive(scene_instance) _wmo_registry[unique_key] = { - "node": instance, + "node": scene_instance, "refs": {tile_key: true}, } refs.append(unique_key) @@ -3306,13 +3367,13 @@ func _drain_wmo_scene_loads() -> void: continue _wmo_scene_load_requests.erase(normalized_rel) if status != ResourceLoader.THREAD_LOAD_LOADED: - _wmo_missing_cache[normalized_rel] = true + _wmo_scene_cache_missing[normalized_rel] = true continue var resource: Resource = ResourceLoader.load_threaded_get(path) - if resource is PackedScene: + if resource is PackedScene and _is_wmo_scene_cache_current(resource as PackedScene): _wmo_scene_resource_cache[normalized_rel] = resource as PackedScene else: - _wmo_missing_cache[normalized_rel] = true + _wmo_scene_cache_missing[normalized_rel] = true func _drain_wmo_render_loads() -> void: @@ -3324,10 +3385,17 @@ func _drain_wmo_render_loads() -> void: continue _wmo_render_load_requests.erase(normalized_rel) if status != ResourceLoader.THREAD_LOAD_LOADED: + _wmo_render_missing_cache[normalized_rel] = true continue var resource: Resource = ResourceLoader.load_threaded_get(path) - if resource != null and resource.get_script() == WMO_STREAMING_SCRIPT: + if ( + resource != null + and resource.get_script() == WMO_STREAMING_SCRIPT + and int(resource.get("format_version")) >= WMO_STREAMING_SCRIPT.FORMAT_VERSION + ): _wmo_render_cache[normalized_rel] = resource + else: + _wmo_render_missing_cache[normalized_rel] = true func _normalize_wmo_rel_path(rel_path: String) -> String: @@ -3340,6 +3408,8 @@ func _get_wmo_render_or_request(rel_path: String) -> Resource: return null if _wmo_render_cache.has(normalized_rel): return _wmo_render_cache[normalized_rel] + if _wmo_render_missing_cache.has(normalized_rel): + return null if _wmo_render_load_requests.has(normalized_rel): return null @@ -3363,21 +3433,21 @@ func _get_wmo_scene_or_request(rel_path: String) -> PackedScene: return null if _wmo_scene_resource_cache.has(normalized_rel): return _wmo_scene_resource_cache[normalized_rel] - if _wmo_missing_cache.has(normalized_rel): + if _wmo_scene_cache_missing.has(normalized_rel): return null if _wmo_scene_load_requests.has(normalized_rel): return null var cache_path := wmo_cache_dir.path_join(normalized_rel.get_basename() + ".tscn") if not ResourceLoader.exists(cache_path): - _wmo_missing_cache[normalized_rel] = true + _wmo_scene_cache_missing[normalized_rel] = true return null if wmo_max_runtime_scene_mb > 0.0: var size_bytes := _get_resource_file_size(cache_path) var max_bytes := int(wmo_max_runtime_scene_mb * 1024.0 * 1024.0) if size_bytes > max_bytes: - _wmo_missing_cache[normalized_rel] = true + _wmo_scene_cache_missing[normalized_rel] = true if debug_streaming: print("StreamingWorld: skipping large WMO %.2f MB: %s" % [ float(size_bytes) / (1024.0 * 1024.0), @@ -3393,7 +3463,7 @@ func _get_wmo_scene_or_request(rel_path: String) -> PackedScene: if err == OK or err == ERR_BUSY: _wmo_scene_load_requests[normalized_rel] = cache_path else: - _wmo_missing_cache[normalized_rel] = true + _wmo_scene_cache_missing[normalized_rel] = true return null @@ -3445,6 +3515,7 @@ func _process_wmo_render_build_jobs() -> void: if mesh_index < meshes.size(): var mesh := meshes[mesh_index] as Mesh if mesh != null: + _refresh_cached_wmo_mesh_materials(mesh) var mesh_instance := MeshInstance3D.new() mesh_instance.name = mesh_names[mesh_index] if mesh_index < mesh_names.size() else "Group_%d" % mesh_index mesh_instance.mesh = mesh @@ -3472,6 +3543,7 @@ func _process_wmo_render_build_jobs() -> void: if multimesh_index < multimeshes.size(): var multimesh := multimeshes[multimesh_index] as MultiMesh if multimesh != null: + _refresh_cached_wmo_mesh_materials(multimesh.mesh) var multimesh_instance := MultiMeshInstance3D.new() multimesh_instance.name = ( multimesh_names[multimesh_index] @@ -3516,12 +3588,32 @@ func _get_resource_file_size(path: String) -> int: return int(length) +func _is_wmo_scene_cache_current(scene: PackedScene) -> bool: + if scene == null: + return false + var instance := scene.instantiate() as Node3D + if instance == null: + return false + var current := _is_wmo_node_cache_current(instance) + instance.free() + return current + + +func _is_wmo_node_cache_current(node: Node) -> bool: + if node == null: + return false + return bool(WMO_BUILDER_SCRIPT.is_scene_cache_current(node)) + + func _instantiate_wmo_scene(rel_path: String, scene: PackedScene, placement: Dictionary) -> Node3D: if scene == null: return null var instance := scene.instantiate() as Node3D if instance == null: return null + if not _is_wmo_node_cache_current(instance): + instance.free() + return null instance.name = rel_path.get_file().get_basename() instance.position = placement.get("pos", Vector3.ZERO) instance.rotation = placement.get("rot", Vector3.ZERO) @@ -3531,6 +3623,7 @@ func _instantiate_wmo_scene(rel_path: String, scene: PackedScene, placement: Dic func _prepare_runtime_wmo_instance(instance: Node3D) -> void: + _refresh_cached_wmo_materials_recursive(instance) if not enable_occlusion_culling: var occluders := instance.get_node_or_null("Occluders") if occluders != null: @@ -3540,6 +3633,163 @@ func _prepare_runtime_wmo_instance(instance: Node3D) -> void: _apply_shadow_cast_recursive(instance, true) +func _refresh_cached_wmo_materials_recursive(node: Node) -> void: + if node is MeshInstance3D: + _refresh_cached_wmo_mesh_materials((node as MeshInstance3D).mesh) + elif node is MultiMeshInstance3D: + var multimesh := (node as MultiMeshInstance3D).multimesh + if multimesh != null: + _refresh_cached_wmo_mesh_materials(multimesh.mesh) + for child in node.get_children(): + _refresh_cached_wmo_materials_recursive(child) + + +func _refresh_cached_wmo_mesh_materials(mesh: Mesh) -> void: + if mesh == null: + return + if int(mesh.get_meta("wow_wmo_material_refresh_version", 0)) >= WMO_MATERIAL_REFRESH_VERSION: + return + mesh.set_meta("wow_wmo_material_refresh_version", WMO_MATERIAL_REFRESH_VERSION) + if not (mesh is ArrayMesh): + return + + var array_mesh := mesh as ArrayMesh + for surface_idx in array_mesh.get_surface_count(): + var rebuilt := _rebuild_cached_wmo_material(array_mesh.surface_get_material(surface_idx)) + if rebuilt != null: + array_mesh.surface_set_material(surface_idx, rebuilt) + + +func _rebuild_cached_wmo_material(material: Material) -> Material: + if material == null or not material.has_meta("texture0_path"): + return null + + var tex0_path := String(material.get_meta("texture0_path", "")) + var tex1_path := String(material.get_meta("texture1_path", "")) + var tex2_path := String(material.get_meta("texture2_path", "")) + var textures := PackedStringArray() + var tex0_index := -1 + var tex1_index := -1 + var tex2_index := -1 + if not tex0_path.is_empty(): + tex0_index = textures.size() + textures.append(tex0_path) + if not tex1_path.is_empty(): + tex1_index = textures.size() + textures.append(tex1_path) + if not tex2_path.is_empty(): + tex2_index = textures.size() + textures.append(tex2_path) + + var diffuse := Color.WHITE + var emissive := Color.BLACK + var secondary := Color.WHITE + if material is ShaderMaterial: + var shader_material := material as ShaderMaterial + var diffuse_value: Variant = shader_material.get_shader_parameter("diffuse_color") + var emissive_value: Variant = shader_material.get_shader_parameter("emissive_color") + var secondary_value: Variant = shader_material.get_shader_parameter("secondary_color") + if diffuse_value is Color: + diffuse = diffuse_value + if emissive_value is Color: + emissive = emissive_value + if secondary_value is Color: + secondary = secondary_value + + var mat_def := { + "texture0": tex0_index, + "texture1": tex1_index, + "texture2": tex2_index, + "flags": int(material.get_meta("wow_flags", 0)), + "shader": int(material.get_meta("wow_shader", 0)), + "blend_mode": int(material.get_meta("wow_blend_mode", 0)), + "diffuse_color": diffuse, + "emissive_color": emissive, + "color2": secondary, + } + return WMO_BUILDER_SCRIPT._build_material(mat_def, textures, extracted_dir) + + +func _adt_m2_placement_basis(rot: Vector3, rel_path: String = "") -> Basis: + if not _is_waterfall_m2_path(rel_path): + return _adt_m2_regular_placement_basis(rot, rel_path) + + # ADTLoader stores MDDF yaw as raw_yaw - 90 degrees for historical Godot + # facing alignment. In our tile-positive ADT world, waterfall models need a + # world-space +90 degree yaw to anchor their top to the source pool. Twist + # the sheet around its own local fall axis afterwards, so the anchor stays in + # place but the alpha plane is not rendered edge-on. + var basis := Basis.from_euler(rot) + return ( + Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET) + * basis + * Basis(_waterfall_local_fall_axis(rel_path), _waterfall_sheet_twist(rel_path)) + ) + + +func _adt_m2_regular_placement_basis(rot: Vector3, rel_path: String = "") -> Basis: + if not _is_elwynn_cliffrock_m2_path(rel_path): + return Basis.from_euler(rot) + + # Elwynn cliff rock M2s are open-backed shell meshes. Plain Godot Euler + # points their hollow side out of the cliff at waterfall placements. Keep the + # correction model-specific so normal props retain the native ADT rotation. + return ( + Basis(Vector3.UP, rot.y + ADT_CLIFFROCK_WORLD_YAW_OFFSET) + * Basis(Vector3.BACK, rot.x) + * Basis(Vector3.RIGHT, -rot.z) + ) + + +func _adt_m2_placement_origin_offset(rot: Vector3, rel_path: String, scale_value: float) -> Vector3: + if not _is_waterfall_m2_path(rel_path): + return Vector3.ZERO + var twist := _waterfall_sheet_twist(rel_path) + if is_zero_approx(twist): + return Vector3.ZERO + var anchor := _waterfall_sheet_twist_anchor(rel_path) + if anchor == Vector3.ZERO: + return Vector3.ZERO + var base := Basis(Vector3.UP, ADT_WATERFALL_WORLD_YAW_OFFSET) * Basis.from_euler(rot) + var twisted := base * Basis(_waterfall_local_fall_axis(rel_path), twist) + return (base * anchor - twisted * anchor) * maxf(scale_value, 0.0001) + + +func _is_waterfall_m2_path(rel_path: String) -> bool: + var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename() + return name == "newwaterfall" or name == "elwynntallwaterfall01" + + +func _is_elwynn_cliffrock_m2_path(rel_path: String) -> bool: + var name := rel_path.replace("\\", "/").to_lower().get_file().get_basename() + return name == "elwynncliffrock01" or name == "elwynncliffrock02" + + +func _waterfall_local_fall_axis(rel_path: String) -> Vector3: + var normalized := rel_path.replace("\\", "/").to_lower() + if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"): + return Vector3(-0.138742, 0.990329, 0.0).normalized() + if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"): + return Vector3(-0.068962, 0.997619, 0.0).normalized() + return Vector3.UP + + +func _waterfall_sheet_twist(rel_path: String) -> float: + var normalized := rel_path.replace("\\", "/").to_lower() + if normalized.ends_with("newwaterfall.m2") or normalized.ends_with("newwaterfall.mdx"): + return PI * 0.5 + if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"): + return -PI * 0.5 + return 0.0 + + +func _waterfall_sheet_twist_anchor(rel_path: String) -> Vector3: + var normalized := rel_path.replace("\\", "/").to_lower() + if normalized.ends_with("elwynntallwaterfall01.m2") or normalized.ends_with("elwynntallwaterfall01.mdx"): + return Vector3(-2.667799, 89.62273, 0.00129) + return Vector3.ZERO + + func _cancel_wmo_build_job(tile_key: String) -> void: if _wmo_build_jobs.has(tile_key): _wmo_build_jobs.erase(tile_key) @@ -3638,11 +3888,13 @@ func _group_tile_m2_task( if normalized.is_empty(): continue - var pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin + var world_pos: Vector3 = placement.get("pos", Vector3.ZERO) + var pos: Vector3 = world_pos - tile_origin var rot: Vector3 = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) - var basis := Basis.from_euler(rot) * Basis(Vector3.UP, M2_RIGHT_YAW_OFFSET) - var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos) + var basis := _adt_m2_placement_basis(rot, normalized) + var offset := _adt_m2_placement_origin_offset(rot, normalized, scale_value) + var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos + offset) if not groups.has(normalized): groups[normalized] = [] @@ -3707,6 +3959,52 @@ func _drain_m2_group_results() -> void: _m2_build_queue.append(key) +func _drain_m2_animation_loads() -> void: + for normalized_rel_variant in _m2_animation_load_requests.keys(): + var normalized_rel := String(normalized_rel_variant) + var pending: Dictionary = _m2_animation_load_requests[normalized_rel] + var path := String(pending.get("path", "")) + if path.is_empty(): + _m2_animation_load_requests.erase(normalized_rel) + _m2_static_animation_cache[normalized_rel] = true + continue + var status := ResourceLoader.load_threaded_get_status(path) + if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED: + continue + pending["status"] = status + _m2_animation_load_requests.erase(normalized_rel) + _m2_animation_finalize_queue.append(pending) + + var ops_left: int = maxi(0, m2_animation_finalize_ops_per_tick) + while ops_left > 0 and not _m2_animation_finalize_queue.is_empty(): + var pending: Dictionary = _m2_animation_finalize_queue.pop_front() + var normalized_rel := String(pending.get("normalized", "")) + if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel): + ops_left -= 1 + continue + if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: + _m2_static_animation_cache[normalized_rel] = true + ops_left -= 1 + continue + + var path := String(pending.get("path", "")) + var resource: Resource = ResourceLoader.load_threaded_get(path) + if resource is PackedScene: + var node = (resource as PackedScene).instantiate() + if node is Node3D: + _repair_m2_animated_materials(normalized_rel, node as Node3D) + var players := _find_animation_players_recursive(node) + if not players.is_empty(): + _m2_animated_scene_cache[normalized_rel] = node as Node3D + if debug_streaming: + print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()]) + ops_left -= 1 + continue + node.free() + _m2_static_animation_cache[normalized_rel] = true + ops_left -= 1 + + func _drain_m2_mesh_loads() -> void: for normalized_rel_variant in _m2_mesh_load_requests.keys(): var normalized_rel := String(normalized_rel_variant) @@ -3739,7 +4037,7 @@ func _drain_m2_mesh_loads() -> void: var resource: Resource = ResourceLoader.load_threaded_get(path) var mesh := _extract_first_mesh_from_m2_resource(resource) if mesh != null: - _m2_mesh_cache[normalized_rel] = mesh + _m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh) else: _m2_missing_cache[normalized_rel] = true ops_left -= 1 @@ -3778,24 +4076,41 @@ func _process_m2_build_jobs() -> void: continue var rel_path: String = String(group_keys[index]) + var normalized_rel := _normalize_m2_rel_path(rel_path) var groups: Dictionary = job.get("groups", {}) var transforms: Array = groups.get(rel_path, []) var offset: int = int(job.get("offset", 0)) - var batch_size: int = maxi(1, m2_multimesh_batch_size) + var animated_prototype: Node3D = null + if enable_m2_animated_instances: + animated_prototype = _get_or_load_m2_native_animated_prototype(rel_path) + if animated_prototype == null: + animated_prototype = _get_or_load_m2_animated_prototype(rel_path) + if enable_m2_animated_instances and _m2_animation_load_requests.has(normalized_rel): + _m2_build_queue.pop_front() + _m2_build_queue.append(key) + ops_left -= 1 + continue + var batch_size: int = ( + maxi(1, m2_animated_instances_per_tick) + if animated_prototype != null + else maxi(1, m2_multimesh_batch_size) + ) var batch_count: int = mini(batch_size, maxi(0, transforms.size() - offset)) var serial: int = int(job.get("serial", 0)) var consumed := batch_count if batch_count > 0: - var mesh := _get_m2_mesh_or_request(rel_path) - if mesh == null: - var normalized_rel := _normalize_m2_rel_path(rel_path) - if not _m2_missing_cache.has(normalized_rel): - _m2_build_queue.pop_front() - _m2_build_queue.append(key) - ops_left -= 1 - continue + if animated_prototype != null: + _materialize_m2_animated_batch(root as Node3D, rel_path, animated_prototype, transforms, offset, batch_count, serial) else: - _materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial) + var mesh := _get_m2_mesh_or_request(rel_path) + if mesh == null: + if not _m2_missing_cache.has(normalized_rel): + _m2_build_queue.pop_front() + _m2_build_queue.append(key) + ops_left -= 1 + continue + else: + _materialize_m2_group_batch(root as Node3D, rel_path, mesh, transforms, offset, batch_count, serial) job["serial"] = serial + 1 if consumed <= 0 or offset + consumed >= transforms.size(): job["index"] = index + 1 @@ -3843,6 +4158,130 @@ func _materialize_m2_group_batch( _set_editor_owner_recursive(mmi) +func _materialize_m2_animated_batch( + m2_root: Node3D, + rel_path: String, + prototype: Node3D, + transforms: Array, + start: int, + count: int, + serial: int) -> void: + if transforms.is_empty() or count <= 0 or prototype == null: + return + + var batch_root := Node3D.new() + batch_root.name = "%s_anim_%d" % [rel_path.get_file().get_basename(), serial] + for i in count: + var instance := prototype.duplicate(Node.DUPLICATE_SIGNALS | Node.DUPLICATE_GROUPS | Node.DUPLICATE_SCRIPTS) as Node3D + if instance == null: + continue + instance.name = "%s_%d" % [rel_path.get_file().get_basename(), start + i] + instance.transform = transforms[start + i] + _apply_visibility_range_recursive(instance, m2_visibility_range) + _apply_shadow_cast_recursive(instance, m2_cast_shadows) + _copy_m2_native_animator_data(prototype, instance) + batch_root.add_child(instance) + _start_m2_animations(instance, rel_path, start + i) + + if batch_root.get_child_count() <= 0: + batch_root.queue_free() + return + m2_root.add_child(batch_root) + _set_editor_owner_recursive(batch_root) + + +func _start_m2_animations(root: Node, rel_path: String, instance_index: int) -> void: + var phase := float(abs(("%s:%d" % [rel_path, instance_index]).hash()) % 1000) / 1000.0 + var native_animators := _find_m2_native_animators_recursive(root) + for animator in native_animators: + if animator.has_method("prepare_runtime"): + animator.prepare_runtime() + animator.set_phase(phase) + if debug_streaming and animator.has_method("runtime_debug_state"): + var state: Dictionary = animator.runtime_debug_state() + print("M2_NATIVE_ANIMATOR path=%s instance=%d prepared=%s processing=%s mesh=%s bones=%d surfaces=%d length=%.2f" % [ + _normalize_m2_rel_path(rel_path), + instance_index, + str(state.get("prepared", false)), + str(state.get("processing", false)), + str(state.get("has_mesh", false)), + int(state.get("bones", 0)), + int(state.get("surfaces", 0)), + float(state.get("length", 0.0)), + ]) + + var players := _find_animation_players_recursive(root) + for player in players: + var animation_name := _choose_default_m2_animation(player, rel_path) + if animation_name.is_empty(): + continue + for name in player.get_animation_list(): + var animation := player.get_animation(name) + if animation != null: + animation.loop_mode = Animation.LOOP_LINEAR + player.play(animation_name) + var animation := player.get_animation(animation_name) + if animation != null and animation.length > 0.0: + player.seek(animation.length * phase, true) + + +func _copy_m2_native_animator_data(source_root: Node, target_root: Node) -> void: + var source_animators := _find_m2_native_animators_recursive(source_root) + var target_animators := _find_m2_native_animators_recursive(target_root) + for i in range(mini(source_animators.size(), target_animators.size())): + var source := source_animators[i] + var target := target_animators[i] + target.mesh_instance_path = source.mesh_instance_path + target.bones = source.bones + target.surfaces = source.surfaces + target.animation_length = source.animation_length + target.playback_speed = source.playback_speed + + +func _find_m2_native_animators_recursive(root: Node) -> Array[Node]: + var result: Array[Node] = [] + _collect_m2_native_animators(root, result) + return result + + +func _collect_m2_native_animators(node: Node, result: Array[Node]) -> void: + if node.get_script() == M2_NATIVE_ANIMATOR_SCRIPT: + result.append(node) + for child in node.get_children(): + _collect_m2_native_animators(child, result) + + +func _choose_default_m2_animation(player: AnimationPlayer, rel_path: String = "") -> String: + var lower := rel_path.to_lower() + var candidates: Array[String] = ["Stand", "Idle", "Run", "Walk"] + if lower.contains("fish"): + candidates = ["Run", "Walk", "Swim", "Stand", "Idle", "Death"] + elif lower.contains("eagle") or lower.contains("bird") or lower.contains("gull"): + candidates = ["Run", "Walk", "Swim", "Stand", "Idle"] + for candidate in candidates: + if player.has_animation(candidate): + return candidate + var names := player.get_animation_list() + for candidate in ["run", "walk", "swim", "stand", "idle", "loop"]: + for name in names: + if String(name).to_lower().contains(candidate): + return String(name) + return String(names[0]) if not names.is_empty() else "" + + +func _find_animation_players_recursive(root: Node) -> Array[AnimationPlayer]: + var result: Array[AnimationPlayer] = [] + _collect_animation_players(root, result) + return result + + +func _collect_animation_players(node: Node, result: Array[AnimationPlayer]) -> void: + if node is AnimationPlayer: + result.append(node as AnimationPlayer) + for child in node.get_children(): + _collect_animation_players(child, result) + + func _finish_m2_build_job(key: String) -> void: if not _m2_build_jobs.has(key): return @@ -3937,11 +4376,11 @@ func _request_m2_mesh_load(normalized_rel: String) -> void: if normalized_rel.is_empty() or _m2_mesh_load_requests.has(normalized_rel): return - for cache_res_path in [ - _get_m2_cache_resource_path(normalized_rel, ".tscn"), - _get_m2_cache_resource_path(normalized_rel, ".glb")]: + for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn", ".glb"]): if not ResourceLoader.exists(cache_res_path): continue + if cache_res_path.get_extension().to_lower() == "glb" and _glb_cache_animation_schema(cache_res_path) == "pivot_prefix_v1": + continue var err := ResourceLoader.load_threaded_request( cache_res_path, "", @@ -3958,6 +4397,12 @@ func _request_m2_mesh_load(normalized_rel: String) -> void: _m2_missing_cache[normalized_rel] = true +func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh: + if mesh == null: + return null + return mesh + + func _extract_first_mesh_from_m2_resource(resource: Resource) -> Mesh: if resource is Mesh: return resource as Mesh @@ -3990,6 +4435,7 @@ func _get_or_load_m2_mesh(rel_path: String) -> Mesh: return null var mesh := _find_first_mesh_recursive(prototype) if mesh != null and not normalized_rel.is_empty(): + mesh = _prepare_m2_mesh_for_runtime(normalized_rel, mesh) _m2_mesh_cache[normalized_rel] = mesh return mesh @@ -4042,8 +4488,8 @@ func _build_tile_m2_placeholders(tile_root: Node3D, tile_origin: Vector3, m2_nam var local_pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin var rot: Vector3 = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) - var xform_basis := Basis.from_euler(rot).scaled(Vector3.ONE * maxf(scale_value, 0.01)) - var xf := Transform3D(xform_basis, local_pos) + var xform_basis := _adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.01)) + var xf := Transform3D(xform_basis, local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value)) if is_tree: tree_transforms.append(xf) else: @@ -4089,10 +4535,12 @@ func _instantiate_m2(rel_path: String, placement: Dictionary, tile_origin: Vecto return null node.name = rel_path.get_file().get_basename() - node.position = placement.get("pos", Vector3.ZERO) - tile_origin - node.rotation = placement.get("rot", Vector3.ZERO) + Vector3(0.0, M2_RIGHT_YAW_OFFSET, 0.0) + var local_pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin + var rot: Vector3 = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) - node.scale = Vector3.ONE * scale_value + node.transform = Transform3D( + _adt_m2_placement_basis(rot, rel_path).scaled(Vector3.ONE * maxf(scale_value, 0.0001)), + local_pos + _adt_m2_placement_origin_offset(rot, rel_path, scale_value)) return node @@ -4129,10 +4577,11 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: return null # Try pre-baked cache (.tscn first, then legacy .glb) - for cache_res_path in [_get_m2_cache_resource_path(normalized_rel, ".tscn"), - _get_m2_cache_resource_path(normalized_rel, ".glb")]: + for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn", ".glb"]): if not ResourceLoader.exists(cache_res_path): continue + if cache_res_path.get_extension().to_lower() == "glb" and _glb_cache_animation_schema(cache_res_path) == "pivot_prefix_v1": + continue var resource: Resource = load(cache_res_path) if resource is PackedScene: var node = (resource as PackedScene).instantiate() @@ -4166,6 +4615,287 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: return prototype +func _get_or_load_m2_native_animated_prototype(rel_path: String) -> Node3D: + var normalized_rel := _normalize_m2_rel_path(rel_path) + if normalized_rel.is_empty() or not _is_m2_native_animation_candidate(normalized_rel): + return null + if _m2_animated_scene_cache.has(normalized_rel): + return _m2_animated_scene_cache[normalized_rel] + if _m2_static_animation_cache.has(normalized_rel): + return null + if not ClassDB.class_exists("M2Loader"): + _m2_static_animation_cache[normalized_rel] = true + return null + + var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(normalized_rel)) + if not FileAccess.file_exists(abs_path): + _m2_static_animation_cache[normalized_rel] = true + return null + + var loader = ClassDB.instantiate("M2Loader") + if loader == null: + _m2_static_animation_cache[normalized_rel] = true + return null + if not loader.has_method("load_m2_animated"): + _m2_static_animation_cache[normalized_rel] = true + return null + + var data: Dictionary = loader.call("load_m2_animated", abs_path) + var animated_surfaces: Array = data.get("animated_surfaces", []) + if data.is_empty() or animated_surfaces.is_empty(): + _m2_static_animation_cache[normalized_rel] = true + return null + + var prototype: Node3D = M2_NATIVE_ANIMATED_BUILDER_SCRIPT.build(data, extracted_dir) + if prototype == null or prototype.get_child_count() <= 0: + _m2_static_animation_cache[normalized_rel] = true + return null + + _m2_animated_scene_cache[normalized_rel] = prototype + if debug_streaming: + print("M2_NATIVE_ANIM_CACHE path=%s surfaces=%d bones=%d anim_id=%d seq=%d score=%d length=%.2f" % [ + normalized_rel, + animated_surfaces.size(), + (data.get("bones", []) as Array).size(), + int(data.get("animation_id", -1)), + int(data.get("animation_sequence_index", -1)), + int(data.get("animation_activity_score", 0)), + float(data.get("animation_length", 0.0)), + ]) + return prototype + + +func _is_m2_native_animation_candidate(normalized_rel: String) -> bool: + return normalized_rel.to_lower().contains("gryphonroost") + + +func _get_or_load_m2_animated_prototype(rel_path: String) -> Node3D: + var normalized_rel := _normalize_m2_rel_path(rel_path) + if normalized_rel.is_empty(): + return null + if _m2_animated_scene_cache.has(normalized_rel): + return _m2_animated_scene_cache[normalized_rel] + if _m2_static_animation_cache.has(normalized_rel): + return null + if _m2_animation_load_requests.has(normalized_rel): + return null + + _request_m2_animation_load(normalized_rel) + return null + + +func _request_m2_animation_load(normalized_rel: String) -> void: + if normalized_rel.is_empty() or _m2_animation_load_requests.has(normalized_rel): + return + var cache_res_path := _find_m2_animated_glb_cache_path(normalized_rel) + if cache_res_path.is_empty(): + _m2_static_animation_cache[normalized_rel] = true + return + var err := ResourceLoader.load_threaded_request( + cache_res_path, + "", + false, + ResourceLoader.CACHE_MODE_REUSE) + if err == OK or err == ERR_BUSY: + _m2_animation_load_requests[normalized_rel] = { + "normalized": normalized_rel, + "path": cache_res_path, + } + else: + _m2_static_animation_cache[normalized_rel] = true + + +func _find_m2_animated_glb_cache_path(normalized_rel: String) -> String: + if not _is_m2_animation_allowed(normalized_rel): + return "" + if _is_m2_animation_denied(normalized_rel): + return "" + for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".glb"]): + if not ResourceLoader.exists(cache_res_path): + continue + if _glb_cache_is_safe_for_runtime_animation(cache_res_path): + return cache_res_path + return "" + + +func _is_m2_animation_allowed(normalized_rel: String) -> bool: + if m2_animated_allowlist_patterns.is_empty(): + return false + var lower := normalized_rel.to_lower() + for pattern in m2_animated_allowlist_patterns: + var needle := String(pattern).strip_edges().to_lower() + if not needle.is_empty() and lower.contains(needle): + return true + return false + + +func _is_m2_animation_denied(normalized_rel: String) -> bool: + var lower := normalized_rel.to_lower() + for pattern in m2_animated_denylist_patterns: + var needle := String(pattern).strip_edges().to_lower() + if not needle.is_empty() and lower.contains(needle): + return true + return false + + +func _repair_m2_animated_materials(normalized_rel: String, animated_root: Node3D) -> void: + if animated_root == null: + return + var material_root := _get_or_load_m2_material_prototype(normalized_rel) + if material_root == null: + return + + var source_meshes := _find_mesh_instances_recursive(material_root) + var target_meshes := _find_mesh_instances_recursive(animated_root) + if source_meshes.is_empty() or target_meshes.is_empty(): + return + + var fallback_material := _first_mesh_material(source_meshes) + for i in target_meshes.size(): + var target := target_meshes[i] + if target == null or target.mesh == null: + continue + var source := source_meshes[mini(i, source_meshes.size() - 1)] + for surface_idx in target.mesh.get_surface_count(): + var material := _mesh_instance_surface_material(source, surface_idx) + if material == null: + material = fallback_material + if material != null: + target.set_surface_override_material(surface_idx, material) + + +func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: + if normalized_rel.is_empty(): + return null + if _m2_scene_cache.has(normalized_rel): + return _m2_scene_cache[normalized_rel] + + for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".tscn"]): + if not ResourceLoader.exists(cache_res_path): + continue + var resource: Resource = load(cache_res_path) + if resource is PackedScene: + var node = (resource as PackedScene).instantiate() + if node is Node3D: + _m2_scene_cache[normalized_rel] = node as Node3D + return node as Node3D + return null + + +func _find_mesh_instances_recursive(root: Node) -> Array[MeshInstance3D]: + var result: Array[MeshInstance3D] = [] + _collect_mesh_instances(root, result) + return result + + +func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void: + if node is MeshInstance3D: + result.append(node as MeshInstance3D) + for child in node.get_children(): + _collect_mesh_instances(child, result) + + +func _first_mesh_material(meshes: Array[MeshInstance3D]) -> Material: + for mesh_instance in meshes: + if mesh_instance == null or mesh_instance.mesh == null: + continue + for surface_idx in mesh_instance.mesh.get_surface_count(): + var material := _mesh_instance_surface_material(mesh_instance, surface_idx) + if material != null: + return material + return null + + +func _mesh_instance_surface_material(mesh_instance: MeshInstance3D, surface_idx: int) -> Material: + if mesh_instance == null or mesh_instance.mesh == null: + return null + if surface_idx < mesh_instance.get_surface_override_material_count(): + var override_material := mesh_instance.get_surface_override_material(surface_idx) + if override_material != null: + return override_material + if surface_idx < mesh_instance.mesh.get_surface_count(): + return mesh_instance.mesh.surface_get_material(surface_idx) + return null + + +func _glb_cache_is_safe_for_runtime_animation(cache_res_path: String) -> bool: + var abs_path := ProjectSettings.globalize_path(cache_res_path) + if not FileAccess.file_exists(abs_path): + return false + var file := FileAccess.open(abs_path, FileAccess.READ) + if file == null or file.get_length() < 20: + return false + var magic := file.get_32() + var version := file.get_32() + file.get_32() + if magic != 0x46546c67 or version != 2: + return false + var json_len := int(file.get_32()) + var chunk_type := file.get_32() + if json_len <= 0 or chunk_type != 0x4e4f534a: + return false + var json_text := file.get_buffer(json_len).get_string_from_utf8() + var parsed = JSON.parse_string(json_text) + if not (parsed is Dictionary): + return false + var gltf := parsed as Dictionary + var animations: Array = gltf.get("animations", []) + if animations.is_empty(): + return false + var primitive_count := _glb_primitive_count(gltf) + if m2_animated_max_primitives > 0 and primitive_count > m2_animated_max_primitives: + return false + var schema := _glb_animation_schema(gltf) + if schema == "pivot_prefix_v1": + return true + # Older simple critter GLBs were generated before the schema marker existed. + # Accept them only after allowlist/denylist filtering and primitive-count checks. + if schema.is_empty(): + return true + if debug_streaming: + print("M2_ANIM_REJECT schema=%s primitives=%d cache=%s" % [schema, primitive_count, cache_res_path]) + return false + + +func _glb_primitive_count(gltf: Dictionary) -> int: + var count := 0 + var meshes: Array = gltf.get("meshes", []) + for mesh_variant in meshes: + if not (mesh_variant is Dictionary): + continue + var primitives: Array = (mesh_variant as Dictionary).get("primitives", []) + count += primitives.size() + return count + + +func _glb_cache_animation_schema(cache_res_path: String) -> String: + var abs_path := ProjectSettings.globalize_path(cache_res_path) + if not FileAccess.file_exists(abs_path): + return "" + var file := FileAccess.open(abs_path, FileAccess.READ) + if file == null or file.get_length() < 20: + return "" + var magic := file.get_32() + var version := file.get_32() + file.get_32() + if magic != 0x46546c67 or version != 2: + return "" + var json_len := int(file.get_32()) + var chunk_type := file.get_32() + if json_len <= 0 or chunk_type != 0x4e4f534a: + return "" + var parsed = JSON.parse_string(file.get_buffer(json_len).get_string_from_utf8()) + if parsed is Dictionary: + return _glb_animation_schema(parsed as Dictionary) + return "" + + +func _glb_animation_schema(gltf: Dictionary) -> String: + var asset: Dictionary = gltf.get("asset", {}) + var extras: Dictionary = asset.get("extras", {}) + return String(extras.get("openwc_m2_anim_schema", "")) + + func _get_or_load_wmo_prototype(rel_path: String) -> Node3D: var normalized_rel := rel_path.replace("\\", "/") if normalized_rel.is_empty(): @@ -4181,9 +4911,11 @@ func _get_or_load_wmo_prototype(rel_path: String) -> Node3D: var resource: Resource = load(cache_path) if resource is PackedScene: var node = (resource as PackedScene).instantiate() - if node is Node3D: + if node is Node3D and _is_wmo_node_cache_current(node): _wmo_prototype_cache[normalized_rel] = node as Node3D return node as Node3D + if node is Node: + (node as Node).free() # Fall back to live WMOLoader if not ClassDB.class_exists("WMOLoader"): @@ -4219,6 +4951,26 @@ func _get_m2_cache_resource_path(rel_path: String, ext: String = ".tscn") -> Str return m2_cache_dir.path_join(stem + ext) +func _get_m2_cache_resource_paths(rel_path: String, extensions: Array[String]) -> PackedStringArray: + var normalized := rel_path.replace("\\", "/") + var lower := normalized.to_lower() + var stems := [ + normalized.get_basename(), + lower.get_basename(), + normalized.get_file().get_basename(), + lower.get_file().get_basename(), + ] + var result := PackedStringArray() + for ext in extensions: + for stem in stems: + if stem.is_empty(): + continue + var path := m2_cache_dir.path_join(stem + ext) + if not result.has(path): + result.append(path) + return result + + func _get_cached_tile_mesh(cache_key: String) -> Mesh: if not _tile_mesh_cache.has(cache_key): return null diff --git a/src/tools/__pycache__/m2_to_gltf.cpython-313.pyc b/src/tools/__pycache__/m2_to_gltf.cpython-313.pyc index cb2e446..4d658db 100644 Binary files a/src/tools/__pycache__/m2_to_gltf.cpython-313.pyc and b/src/tools/__pycache__/m2_to_gltf.cpython-313.pyc differ diff --git a/src/tools/bake_m2_cache.gd b/src/tools/bake_m2_cache.gd index 2cb2a24..e86ca59 100644 --- a/src/tools/bake_m2_cache.gd +++ b/src/tools/bake_m2_cache.gd @@ -3,6 +3,8 @@ ## Usage: ## godot --headless --path --script res://src/tools/bake_m2_cache.gd -- \ ## --map Azeroth --extracted res://data/extracted --output res://data/cache/m2_glb --force +## Add --only-pattern waterfall to rebuild only matching model paths. +## Add --glb-animations to also emit animated GLB scenes through src/tools/m2_to_gltf.py. extends SceneTree const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") @@ -13,6 +15,10 @@ func _initialize() -> void: var extracted := _res(_arg(args, "--extracted", "res://data/extracted")) var output_dir := _res(_arg(args, "--output", "res://data/cache/m2_glb")) var force := args.has("--force") + var glb_animations := args.has("--glb-animations") + var only_patterns := _arg_values(args, "--only-pattern") + var python_exe := _arg(args, "--python", "python") + var converter := _res(_arg(args, "--converter", "res://src/tools/m2_to_gltf.py")) if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("M2Loader"): push_error("GDExtension not loaded. Rebuild first.") @@ -47,7 +53,7 @@ func _initialize() -> void: var norm := str(rel).replace("\\", "/").to_lower() if norm.ends_with(".mdx") or norm.ends_with(".mdl"): norm = norm.get_basename() + ".m2" - if not norm.is_empty(): + if not norm.is_empty() and _matches_only_patterns(norm, only_patterns): unique[norm] = true scanned += 1 @@ -55,8 +61,10 @@ func _initialize() -> void: var m2_loader = ClassDB.instantiate("M2Loader") var baked := 0 + var baked_glb := 0 var skipped := 0 var failed := 0 + var failed_glb := 0 var total := unique.size() var i := 0 @@ -64,16 +72,22 @@ func _initialize() -> void: i += 1 var stem: String = rel_path.get_basename() var out_path := output_dir.path_join(stem + ".tscn") - - if not force and ResourceLoader.exists(out_path): - skipped += 1 - continue + var out_glb_path := output_dir.path_join(stem + ".glb") var abs_m2 := ProjectSettings.globalize_path(extracted.path_join(rel_path)) if not FileAccess.file_exists(abs_m2): failed += 1 continue + if not force and ResourceLoader.exists(out_path): + if glb_animations: + if _bake_glb_animation_cache(python_exe, converter, abs_m2, output_dir, out_glb_path, false): + baked_glb += 1 + else: + failed_glb += 1 + skipped += 1 + continue + var data: Dictionary = m2_loader.call("load_m2", abs_m2) if data.is_empty() or data.get("vertices", PackedVector3Array()).is_empty(): failed += 1 @@ -102,10 +116,17 @@ func _initialize() -> void: continue baked += 1 + if glb_animations: + if _bake_glb_animation_cache(python_exe, converter, abs_m2, output_dir, out_glb_path, force): + baked_glb += 1 + else: + failed_glb += 1 if i % 50 == 0 or i == total: - print("[%d/%d] baked=%d skipped=%d failed=%d" % [i, total, baked, skipped, failed]) + print("[%d/%d] baked=%d glb=%d skipped=%d failed=%d glb_failed=%d" % [ + i, total, baked, baked_glb, skipped, failed, failed_glb]) - print("Done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed]) + print("Done. baked=%d glb=%d skipped=%d failed=%d glb_failed=%d" % [ + baked, baked_glb, skipped, failed, failed_glb]) quit(0) @@ -116,12 +137,65 @@ func _arg(args: PackedStringArray, name: String, default: String) -> String: return default +func _arg_values(args: PackedStringArray, name: String) -> PackedStringArray: + var result := PackedStringArray() + for i in args.size(): + if args[i] != name or i + 1 >= args.size(): + continue + for part in String(args[i + 1]).split(",", false): + var value := part.strip_edges().to_lower() + if not value.is_empty(): + result.append(value) + return result + + +func _matches_only_patterns(path: String, patterns: PackedStringArray) -> bool: + if patterns.is_empty(): + return true + var lower := path.to_lower() + for pattern in patterns: + if lower.contains(pattern): + return true + return false + + func _res(path: String) -> String: if path.begins_with("res://") or path.begins_with("user://"): return path return "res://" + path.trim_prefix("./").trim_prefix("/") +func _bake_glb_animation_cache( + python_exe: String, + converter: String, + abs_m2: String, + output_dir: String, + out_glb_path: String, + force: bool) -> bool: + var abs_out_glb := ProjectSettings.globalize_path(out_glb_path) + if not force and FileAccess.file_exists(abs_out_glb): + return true + var abs_converter := ProjectSettings.globalize_path(converter) + var abs_output := ProjectSettings.globalize_path(output_dir) + if not FileAccess.file_exists(abs_converter): + push_warning("M2 GLB converter not found: %s" % converter) + return false + var stdout := [] + var exit_code := OS.execute( + python_exe, + [abs_converter, abs_m2, abs_output], + stdout, + true, + false) + if exit_code != 0: + push_warning("animated GLB bake failed for %s exit=%d\n%s" % [ + abs_m2, + exit_code, + "\n".join(stdout)]) + return false + return FileAccess.file_exists(abs_out_glb) + + func _set_owner_recursive(node: Node, owner_root: Node) -> void: for child in node.get_children(): child.owner = owner_root diff --git a/src/tools/m2_to_gltf.py b/src/tools/m2_to_gltf.py index 20dd0df..debd713 100644 --- a/src/tools/m2_to_gltf.py +++ b/src/tools/m2_to_gltf.py @@ -650,7 +650,11 @@ class SkinParser: class GltfBuilder: def __init__(self): - self.asset = {"version": "2.0", "generator": "WoW M2 to GLTF converter"} + self.asset = { + "version": "2.0", + "generator": "WoW M2 to GLTF converter", + "extras": {"openwc_m2_anim_schema": "pivot_prefix_v1"}, + } self.nodes = [] self.meshes = [] self.skins = [] @@ -1027,36 +1031,41 @@ class GltfBuilder: def _build_skeleton(self, m2: M2Parser): bone_node_indices = [] + pivot_node_indices = [] for i, bone in enumerate(m2.bones): - node_idx = len(self.nodes) - bone_node_indices.append(node_idx) + pivot_idx = len(self.nodes) + anim_idx = pivot_idx + 1 + pivot_node_indices.append(pivot_idx) + bone_node_indices.append(anim_idx) - # Pivot in GLTF space (world-space first; parent-relative below) + # Pivot node holds the bind-pose offset. The child bone node stays + # at identity and receives animation channels. This mirrors WoW's + # T(pivot) * anim * T(-pivot) behavior closely enough for GLTF skinning. px, py, pz = wow_pos_to_gltf(*bone['pivot']) - - node = { - "name": f"bone_{i}", + self.nodes.append({ + "name": f"bone_{i}_pivot", "translation": [px, py, pz], - "rotation": [0.0, 0.0, 0.0, 1.0], - "scale": [1.0, 1.0, 1.0], - } - self.nodes.append(node) + "children": [anim_idx], + }) + self.nodes.append({ + "name": f"bone_{i}", + }) - # Wire up children + # Wire pivot nodes into the hierarchy. for i, bone in enumerate(m2.bones): parent = bone['parent'] if parent >= 0 and parent < len(m2.bones): parent_node = bone_node_indices[parent] - child_node = bone_node_indices[i] + child_node = pivot_node_indices[i] self.nodes[parent_node].setdefault("children", []).append(child_node) - # Convert world-space pivots to parent-relative translations + # Convert world-space pivots to parent-relative pivot translations. for i, bone in enumerate(m2.bones): parent = bone['parent'] if parent >= 0 and parent < len(m2.bones): ppx, ppy, ppz = wow_pos_to_gltf(*m2.bones[parent]['pivot']) - node = self.nodes[bone_node_indices[i]] + node = self.nodes[pivot_node_indices[i]] t = node["translation"] node["translation"] = [t[0]-ppx, t[1]-ppy, t[2]-ppz] @@ -1064,7 +1073,7 @@ class GltfBuilder: # WoW models have multiple root bones (main skeleton + attachment/socket # bones each with parent=-1). Wrap them all under a single virtual # "Armature" node at the origin so the skin has a valid common root. - root_bone_nodes = [bone_node_indices[i] + root_bone_nodes = [pivot_node_indices[i] for i, b in enumerate(m2.bones) if b['parent'] < 0] armature_idx = len(self.nodes) self.nodes.append({ @@ -1310,20 +1319,12 @@ class GltfBuilder: node_idx = bone_node_indices[bone_idx] # Translation - # M2 t_track values are OFFSETS from the bone's rest position (pivot - # relative to parent pivot). GLTF animation overwrites node.translation - # entirely, so we must emit: rest_local + m2_offset (both in GLTF space). + # Pivot offset lives on the parent pivot node. The animated bone node + # receives only the M2 translation offset. ts, vals = m2._resolve_track(bone['t_track'], seq_idx, 'vec3') if ts and vals: - rest = self.nodes[node_idx].get("translation", [0.0, 0.0, 0.0]) - rx, ry, rz = rest[0], rest[1], rest[2] - # Use a factory to capture rx/ry/rz by value (avoid late-binding bug) - def _make_trans_fn(rx, ry, rz): - def fn(v): - cx, cy, cz = wow_pos_to_gltf(*v) - return (rx + cx, ry + cy, rz + cz) - return fn - s_idx = self._anim_sampler(ts, vals, 'VEC3', _make_trans_fn(rx, ry, rz)) + s_idx = self._anim_sampler(ts, vals, 'VEC3', + lambda v: wow_pos_to_gltf(*v)) if s_idx is not None: samplers.append(s_idx) channels.append({"sampler": len(samplers)-1,