победа над водопадами, шейдеры m2

This commit is contained in:
2026-07-07 10:45:43 +04:00
parent 8738c2495d
commit 44614a79e4
38 changed files with 4820 additions and 192 deletions
+197 -2
View File
@@ -243,6 +243,140 @@ src/tools/m2_to_gltf.py
- WoW material flags не полностью совпадают; - WoW material flags не полностью совпадают;
- billboards/leaf bones пока не как в клиенте. - 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 Rendering
Цель: WMO не должны фризить сцену при догрузке, особенно большие здания/города. Цель: WMO не должны фризить сцену при догрузке, особенно большие здания/города.
@@ -324,6 +458,31 @@ addons/mpq_extractor/loaders/wow_liquid_material.gd
- LiquidType.dbc texture selection пока не используется; - LiquidType.dbc texture selection пока не используется;
- waterfalls/flowing water требуют отдельного M2/material path. - 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 ## Sky And Lighting
Добавлен `WowSkyController`: Добавлен `WowSkyController`:
@@ -451,7 +610,7 @@ res://data/cache/wmo_render_v1
Baked terrain required: 4 Baked terrain required: 4
Splat terrain required: 1 Splat terrain required: 1
Control splat required: 3 Control splat required: 3
WMO streaming resource: 1 WMO streaming resource: 2
``` ```
## Debug Logs ## 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 animation/skinning.
- M2 particles/ribbons. - 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. - True WMO portal/room culling.
- Selective cheap dynamic shadows. - Selective cheap dynamic shadows.
- Liquid depth fade/shore blending. - 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. - Better skybox transition validation per zone.
- Automated renderer regression scenes/screenshots. - 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 ## 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. 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.
+100 -12
View File
@@ -21,6 +21,10 @@ static var _baked_texture_material_cache: Dictionary = {}
static var _alpha_texture_cache: Dictionary = {} static var _alpha_texture_cache: Dictionary = {}
static var _layered_material_cache: Dictionary = {} static var _layered_material_cache: Dictionary = {}
static func _ensure_wow_shader_globals() -> void:
pass
## Build a Node3D with 256 terrain chunk meshes. ## Build a Node3D with 256 terrain chunk meshes.
## `extracted_dir` must be an absolute path to the extracted/ folder so BLPs can be loaded. ## `extracted_dir` must be an absolute path to the extracted/ folder so BLPs can be loaded.
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
@@ -1253,6 +1257,7 @@ static func _get_control_splat_shader() -> Shader:
if _control_splat_shader: if _control_splat_shader:
return _control_splat_shader return _control_splat_shader
_ensure_wow_shader_globals()
_control_splat_shader = Shader.new() _control_splat_shader = Shader.new()
_control_splat_shader.code = """ _control_splat_shader.code = """
shader_type spatial; shader_type spatial;
@@ -1269,10 +1274,34 @@ uniform float ambient = 0.62;
uniform float diffuse = 0.38; uniform float diffuse = 0.38;
uniform float hemi = 0.12; uniform float hemi = 0.12;
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) { vec3 sample_layer(float layer, vec2 continuous_chunk_uv) {
return texture(terrain_tex, vec3(continuous_chunk_uv * uv_scale, layer), mip_bias).rgb; 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() { void fragment() {
vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999)); vec2 tile_uv = clamp(UV, vec2(0.0), vec2(0.999999));
vec2 chunk_uv = tile_uv * 16.0; vec2 chunk_uv = tile_uv * 16.0;
@@ -1300,15 +1329,18 @@ void fragment() {
sample_layer(layers.z, chunk_uv) * w2 + sample_layer(layers.z, chunk_uv) * w2 +
sample_layer(layers.w, chunk_uv) * w3; sample_layer(layers.w, chunk_uv) * w3;
vec3 n = normalize(NORMAL); vec3 n = normalize(world_normal);
if (!FRONT_FACING) { if (!FRONT_FACING) {
n = -n; n = -n;
} }
vec3 l = normalize(light_dir); vec3 l = normalize(wow_light_dir);
float ndl = max(dot(n, l), 0.0); float ndl = max(dot(n, l), 0.0);
float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
float light = ambient + diffuse * ndl + hemi * hemi_term; vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22);
ALBEDO = albedo * light; 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 return _control_splat_shader
@@ -1465,6 +1497,7 @@ static func _get_terrain_shader() -> Shader:
if _terrain_shader: if _terrain_shader:
return _terrain_shader return _terrain_shader
_ensure_wow_shader_globals()
_terrain_shader = Shader.new() _terrain_shader = Shader.new()
_terrain_shader.code = """ _terrain_shader.code = """
shader_type spatial; shader_type spatial;
@@ -1487,6 +1520,30 @@ uniform float ambient = 0.62;
uniform float diffuse = 0.38; uniform float diffuse = 0.38;
uniform float hemi = 0.12; uniform float hemi = 0.12;
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() { void fragment() {
vec2 tiled_uv = UV * uv_scale; vec2 tiled_uv = UV * uv_scale;
@@ -1516,15 +1573,18 @@ void fragment() {
if (layer_count > 2) { albedo += c2 * w2; } if (layer_count > 2) { albedo += c2 * w2; }
if (layer_count > 3) { albedo += c3 * w3; } if (layer_count > 3) { albedo += c3 * w3; }
vec3 n = normalize(NORMAL); vec3 n = normalize(world_normal);
if (!FRONT_FACING) { if (!FRONT_FACING) {
n = -n; n = -n;
} }
vec3 l = normalize(light_dir); vec3 l = normalize(wow_light_dir);
float ndl = max(dot(n, l), 0.0); float ndl = max(dot(n, l), 0.0);
float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
float light = ambient + diffuse * ndl + hemi * hemi_term; vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22);
ALBEDO = albedo.rgb * light; 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 return _terrain_shader
@@ -1534,6 +1594,7 @@ static func _get_single_texture_shader() -> Shader:
if _single_texture_shader: if _single_texture_shader:
return _single_texture_shader return _single_texture_shader
_ensure_wow_shader_globals()
_single_texture_shader = Shader.new() _single_texture_shader = Shader.new()
_single_texture_shader.code = """ _single_texture_shader.code = """
shader_type spatial; shader_type spatial;
@@ -1548,6 +1609,23 @@ uniform float ambient = 0.62;
uniform float diffuse = 0.38; uniform float diffuse = 0.38;
uniform float hemi = 0.12; uniform float hemi = 0.12;
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 sample_sharp(sampler2D tex, vec2 uv) {
vec3 center = texture(tex, uv, mip_bias).rgb; vec3 center = texture(tex, uv, mip_bias).rgb;
if (sharpen_strength <= 0.001) { 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)); 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() { void fragment() {
vec3 n = normalize(NORMAL); vec3 n = normalize(world_normal);
if (!FRONT_FACING) { if (!FRONT_FACING) {
n = -n; n = -n;
} }
vec3 l = normalize(light_dir); vec3 l = normalize(wow_light_dir);
float ndl = max(dot(n, l), 0.0); float ndl = max(dot(n, l), 0.0);
float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); float hemi_term = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
float light = ambient + diffuse * ndl + hemi * hemi_term; vec3 ambient_tint = mix(vec3(0.74), max(wow_ambient_color.rgb, vec3(0.08)), 0.22);
ALBEDO = sample_sharp(tex0, UV * uv_scale) * light; 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 return _single_texture_shader
+345 -33
View File
@@ -5,6 +5,8 @@
## add_child(node) ## add_child(node)
class_name M2Builder class_name M2Builder
const WOW_M2_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_m2_material.gd")
static var _texture_cache: Dictionary = {} static var _texture_cache: Dictionary = {}
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: 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 verts: PackedVector3Array = data.get("vertices", PackedVector3Array())
var normals: PackedVector3Array = data.get("normals", PackedVector3Array()) var normals: PackedVector3Array = data.get("normals", PackedVector3Array())
var uvs: PackedVector2Array = data.get("uvs", PackedVector2Array()) var uvs: PackedVector2Array = data.get("uvs", PackedVector2Array())
var uvs2: PackedVector2Array = data.get("uvs2", PackedVector2Array())
var indices: PackedInt32Array = data.get("indices", PackedInt32Array()) var indices: PackedInt32Array = data.get("indices", PackedInt32Array())
var batches: Array = data.get("batches", []) 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 texture_flags: PackedInt32Array = data.get("texture_flags", PackedInt32Array())
var materials: Array = data.get("materials", []) var materials: Array = data.get("materials", [])
var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array()) var tex_combos: PackedInt32Array = data.get("texture_combos", PackedInt32Array())
var 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 model_path: String = str(data.get("model_path", ""))
var mesh := ArrayMesh.new() var mesh := ArrayMesh.new()
@@ -52,6 +63,8 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
arrays[Mesh.ARRAY_NORMAL] = normals arrays[Mesh.ARRAY_NORMAL] = normals
if uvs.size() == verts.size(): if uvs.size() == verts.size():
arrays[Mesh.ARRAY_TEX_UV] = uvs arrays[Mesh.ARRAY_TEX_UV] = uvs
if uvs2.size() == verts.size():
arrays[Mesh.ARRAY_TEX_UV2] = uvs2
arrays[Mesh.ARRAY_INDEX] = batch_indices arrays[Mesh.ARRAY_INDEX] = batch_indices
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) 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_types,
texture_flags, texture_flags,
tex_combos, tex_combos,
tex_coord_combos,
tex_weight_combos,
tex_transform_combos,
tex_combiner_combos,
m2_colors,
tex_weights,
tex_transforms,
m2_flags,
extracted_dir, extracted_dir,
model_path)) model_path,
batch))
if mesh.get_surface_count() == 0: if mesh.get_surface_count() == 0:
return root return root
@@ -87,50 +109,340 @@ static func _build_material(
texture_types: PackedInt32Array, texture_types: PackedInt32Array,
texture_flags: PackedInt32Array, texture_flags: PackedInt32Array,
tex_combos: 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, 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)) 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) var blend_mode: int = mat_def.get("blend_mode", 0)
match blend_mode: var tex_flags := 0
0: var tex: Texture2D = null
mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED var tex2: Texture2D = null
1: var tex3: Texture2D = null
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR var tex4: Texture2D = null
mat.alpha_scissor_threshold = 0.5 var tex2_flags := 0
3, 4, 6: var tex3_flags := 0
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA var tex4_flags := 0
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY
_:
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_OPAQUE_ONLY
if texture_combo_index >= 0 and texture_combo_index < tex_combos.size(): var resolved0 := _resolve_combo_texture(texture_combo_index, textures, texture_types, texture_flags, tex_combos, extracted_dir, model_path)
var tex_idx: int = tex_combos[texture_combo_index] tex = resolved0.get("texture", null)
if tex_idx >= 0 and tex_idx < textures.size(): 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_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_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 var tex_flags := int(texture_flags[tex_idx]) if tex_idx < texture_flags.size() else 0
if tex_path.is_empty(): if tex_path.is_empty():
tex_path = _default_texture_path(model_path, tex_type) tex_path = _default_texture_path(model_path, tex_type)
var tex: Texture2D = null
if not tex_path.is_empty(): if not tex_path.is_empty():
var tex := _load_texture(tex_path, extracted_dir) tex = _load_texture(tex_path, extracted_dir)
if tex: return {
mat.albedo_texture = tex "texture": tex,
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC "flags": tex_flags,
mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, (tex_flags & 0x03) != 0) "path": tex_path,
}
return mat
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: static func _default_texture_path(model_path: String, texture_type: int) -> String:
@@ -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
@@ -0,0 +1 @@
uid://dngobeqkb20pc
+58 -27
View File
@@ -7,7 +7,9 @@ class_name WMOBuilder
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
const WOW_LIQUID_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_liquid_material.gd") const 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 BUILD_OCCLUDERS := false
const OCCLUDER_MIN_TRIANGLES := 16 const OCCLUDER_MIN_TRIANGLES := 16
@@ -29,11 +31,20 @@ static func clear_caches() -> void:
_m2_missing_cache.clear() _m2_missing_cache.clear()
_texture_cache.clear() _texture_cache.clear()
WOW_LIQUID_MATERIAL.clear_cache() 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. # Returns a Node3D containing one MeshInstance3D per WMO group.
static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
var root := Node3D.new() var root := Node3D.new()
root.name = "WMO" root.name = "WMO"
root.set_meta(WMO_BUILDER_FORMAT_META, WMO_BUILDER_FORMAT_VERSION)
if not data.has("groups"): if not data.has("groups"):
return root return root
@@ -42,7 +53,7 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D:
var materials: Array = data.get("materials", []) var materials: Array = data.get("materials", [])
# Build Godot materials from WMO material definitions # Build Godot materials from WMO material definitions
var godot_materials: Array[StandardMaterial3D] = [] var godot_materials: Array[Material] = []
for mat_def in materials: for mat_def in materials:
godot_materials.append(_build_material(mat_def, textures, extracted_dir)) 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 pos: Vector3 = p.get("pos", Vector3.ZERO)
var rot_q: Quaternion = p.get("rot", Quaternion.IDENTITY) var rot_q: Quaternion = p.get("rot", Quaternion.IDENTITY)
var scl: float = float(p.get("scale", 1.0)) 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) var xform := Transform3D(basis, pos)
if not groups.has(rel_path): if not groups.has(rel_path):
groups[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( static func _build_group_mesh(
g: Dictionary, g: Dictionary,
godot_mats: Array[StandardMaterial3D]) -> MeshInstance3D: godot_mats: Array[Material]) -> MeshInstance3D:
var verts: PackedVector3Array = g.get("vertices", PackedVector3Array()) var verts: PackedVector3Array = g.get("vertices", PackedVector3Array())
var normals: PackedVector3Array = g.get("normals", 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( static func _build_material(
mat_def: Dictionary, mat_def: Dictionary,
textures: PackedStringArray, textures: PackedStringArray,
extracted_dir: String = "") -> StandardMaterial3D: extracted_dir: String = "") -> Material:
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)
var blend_mode: int = mat_def.get("blend_mode", 0) var blend_mode: int = mat_def.get("blend_mode", 0)
match blend_mode: var flags: int = int(mat_def.get("flags", 0))
0: mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED var shader_id: int = int(mat_def.get("shader", 0))
1: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR var tex: Texture2D = null
_: mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA 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) # Texture is loaded later by the scene assembler (needs BLP→Image conversion)
# Store the path as metadata for the assembler to pick up # Store the path as metadata for the assembler to pick up
var tex0_id: int = mat_def.get("texture0", -1) var tex0_id: int = mat_def.get("texture0", -1)
if tex0_id >= 0 and tex0_id < textures.size(): if tex0_id >= 0 and tex0_id < textures.size():
var tex_path: String = str(textures[tex0_id]).replace("\\", "/") tex_path = str(textures[tex0_id]).replace("\\", "/")
mat.set_meta("texture0_path", tex_path) tex = _load_texture(tex_path, extracted_dir)
var tex := _load_texture(tex_path, extracted_dir) var tex1_id: int = mat_def.get("texture1", -1)
if tex: if tex1_id >= 0 and tex1_id < textures.size():
mat.albedo_texture = tex tex2_path = str(textures[tex1_id]).replace("\\", "/")
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC tex2 = _load_texture(tex2_path, extracted_dir)
mat.set_flag(BaseMaterial3D.FLAG_USE_TEXTURE_REPEAT, true) 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)) var diffuse_color: Color = mat_def.get("diffuse_color", Color.WHITE)
mat.set_meta("wow_shader", mat_def.get("shader", 0)) 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 return mat
@@ -9,6 +9,10 @@ static func clear_cache() -> void:
_cache.clear() _cache.clear()
static func _ensure_wow_shader_globals() -> void:
pass
static func build(liquid_id: int) -> ShaderMaterial: static func build(liquid_id: int) -> ShaderMaterial:
var key := "adt:%d" % liquid_id var key := "adt:%d" % liquid_id
if _cache.has(key): if _cache.has(key):
@@ -108,6 +112,7 @@ static func _get_shader() -> Shader:
if _shader: if _shader:
return _shader return _shader
_ensure_wow_shader_globals()
_shader = Shader.new() _shader = Shader.new()
_shader.code = """ _shader.code = """
shader_type spatial; shader_type spatial;
@@ -123,7 +128,12 @@ uniform float fresnel_power = 2.3;
uniform float emission_strength = 0.025; uniform float emission_strength = 0.025;
uniform float magma_mode = 0.0; 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 world_pos;
varying vec3 view_pos;
float hash21(vec2 p) { float hash21(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); 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); 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() { void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz;
float t = TIME * wave_speed; float t = TIME * wave_speed;
float wave_a = sin(world_pos.x * wave_scale + t); float wave_a = sin(world_pos.x * wave_scale + t);
float wave_b = cos(world_pos.z * wave_scale * 1.37 - t * 1.21); float wave_b = cos(world_pos.z * wave_scale * 1.37 - t * 1.21);
@@ -170,7 +188,7 @@ void fragment() {
EMISSION = color * emission_strength; 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); ALPHA = clamp(alpha_base + fresnel * 0.18 + ripple * 0.06, 0.0, 1.0);
} }
""" """
@@ -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);",
]
@@ -0,0 +1 @@
uid://dbyyg7wwr0y3w
@@ -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));",
]
@@ -0,0 +1 @@
uid://c1ju24mvhoi5e
+31
View File
@@ -27,3 +27,34 @@ enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg")
rendering_device/driver.windows="d3d12" rendering_device/driver.windows="d3d12"
rendering/rendering_device/d3d12/max_resource_descriptors=1048576 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
}
+1
Submodule reference/WoWee added at 626243e937
Submodule reference/WowUnreal added at c2a4b9827b
+1
Submodule reference/whoa added at 74bc963a1c
+576 -3
View File
@@ -6,10 +6,17 @@
#include <godot_cpp/variant/packed_vector3_array.hpp> #include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_vector2_array.hpp> #include <godot_cpp/variant/packed_vector2_array.hpp>
#include <godot_cpp/variant/packed_int32_array.hpp> #include <godot_cpp/variant/packed_int32_array.hpp>
#include <godot_cpp/variant/packed_float32_array.hpp>
#include <godot_cpp/variant/packed_vector4_array.hpp>
#include <godot_cpp/variant/color.hpp>
#include <fstream> #include <fstream>
#include <cstring> #include <cstring>
#include <cstdio> #include <cstdio>
#include <cmath>
#include <algorithm>
#include <cstdint>
#include <cctype>
using namespace godot; using namespace godot;
@@ -54,6 +61,38 @@ struct M2Header {
uint32_t ofsBoneCombos; uint32_t ofsBoneCombos;
uint32_t nTextureCombos; // offset 128 uint32_t nTextureCombos; // offset 128
uint32_t ofsTextureCombos; // offset 132 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 // Total: 34 × 4 = 136 bytes
@@ -79,6 +118,70 @@ struct M2Material {
uint16_t blendingMode; // 0=opaque 1=alpha_key 2=alpha_blend 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 { struct SkinHeader {
uint32_t magic; // 'SKIN' LE = 0x4E494B53 uint32_t magic; // 'SKIN' LE = 0x4E494B53
uint32_t nIndices; uint32_t nIndices;
@@ -164,10 +267,203 @@ static const T *safe_array(const std::vector<uint8_t> &buf, uint32_t ofs, uint32
return reinterpret_cast<const T *>(buf.data() + ofs); return reinterpret_cast<const T *>(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<typename T>
static const T *track_sequence_array(const std::vector<uint8_t> &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<M2ArrayRef>(buf, outer.offset, outer.count);
if (!inner_arrays) return nullptr;
const M2ArrayRef &inner = inner_arrays[sequence_index];
const T *items = safe_array<T>(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<uint8_t> &buf, const M2Track &track, uint32_t sequence_index) {
if (sequence_index >= track.values.count) return 0;
const M2ArrayRef *inner_arrays = safe_array<M2ArrayRef>(buf, track.values.offset, track.values.count);
if (!inner_arrays) return 0;
return inner_arrays[sequence_index].count;
}
template<typename T>
static const T *first_track_value(const std::vector<uint8_t> &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<T>(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<uint8_t> &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<uint32_t>(buf, track.timestamps, i, time_count);
const M2Float3 *values = track_sequence_array<M2Float3>(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<uint8_t> &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<uint8_t> &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<uint8_t> &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<uint32_t>(buf, track.timestamps, sequence_index, time_count);
const M2Float3 *values = track_sequence_array<M2Float3>(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<uint8_t> &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<uint32_t>(buf, track.timestamps, sequence_index, time_count);
const M2CompQuat *values = track_sequence_array<M2CompQuat>(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 // Core parser
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string &path) { Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string &path, bool include_animation) {
if (buf.size() < sizeof(M2Header)) return Dictionary(); if (buf.size() < sizeof(M2Header)) return Dictionary();
const auto &hdr = *reinterpret_cast<const M2Header *>(buf.data()); const auto &hdr = *reinterpret_cast<const M2Header *>(buf.data());
@@ -178,13 +474,14 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
// ── Vertices ───────────────────────────────────────────────────────────── // ── Vertices ─────────────────────────────────────────────────────────────
PackedVector3Array vertices, normals; PackedVector3Array vertices, normals;
PackedVector2Array uvs; PackedVector2Array uvs, uvs2;
const auto *verts = safe_array<M2Vertex>(buf, hdr.ofsVertices, hdr.nVertices); const auto *verts = safe_array<M2Vertex>(buf, hdr.ofsVertices, hdr.nVertices);
if (verts && hdr.nVertices > 0) { if (verts && hdr.nVertices > 0) {
vertices.resize(hdr.nVertices); vertices.resize(hdr.nVertices);
normals.resize(hdr.nVertices); normals.resize(hdr.nVertices);
uvs.resize(hdr.nVertices); uvs.resize(hdr.nVertices);
uvs2.resize(hdr.nVertices);
for (uint32_t i = 0; i < hdr.nVertices; ++i) { for (uint32_t i = 0; i < hdr.nVertices; ++i) {
const auto &v = verts[i]; const auto &v = verts[i];
// WoW model space (X right, Y forward, Z up) → Godot (X right, Y up, Z back) // 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<uint8_t> &buf, const std::string
// (D3D/Vulkan convention) — pass UVs through unchanged. Same as // (D3D/Vulkan convention) — pass UVs through unchanged. Same as
// wmo_loader's MOTV handling. // wmo_loader's MOTV handling.
uvs[i] = Vector2(v.texCoords[0], v.texCoords[1]); 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<uint8_t> &buf, const std::string
texture_combos.push_back((int)tc_arr[i]); texture_combos.push_back((int)tc_arr[i]);
} }
PackedInt32Array texture_coord_combos;
const auto *tcc_arr = safe_array<uint16_t>(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<uint16_t>(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<uint16_t>(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<uint16_t>(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 ──────────────────────────────────────────────────────────── // ── Skin file ────────────────────────────────────────────────────────────
Array m2_colors;
const auto *color_arr = safe_array<M2Color>(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<M2Float3>(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<int16_t>(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<M2TextureWeight>(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<int16_t>(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<M2TextureTransform>(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<M2Float3>(buf, transform_arr[i].translationTrack);
if (trans) {
translation = Vector2(trans->v[0], trans->v[1]);
}
const M2Float3 *scl = first_track_value<M2Float3>(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 <basename>00.skin in the same directory as the .m2 // Find <basename>00.skin in the same directory as the .m2
std::string skin_path = path; std::string skin_path = path;
{ {
@@ -257,7 +640,16 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
auto skin_buf = read_file(skin_path); auto skin_buf = read_file(skin_path);
PackedInt32Array indices; PackedInt32Array indices;
PackedInt32Array bone_lookup_table;
Array batches; Array batches;
Array animated_surfaces;
const uint16_t *bone_combo_arr = safe_array<uint16_t>(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)) { if (skin_buf.size() >= sizeof(SkinHeader)) {
const auto &skin = *reinterpret_cast<const SkinHeader *>(skin_buf.data()); const auto &skin = *reinterpret_cast<const SkinHeader *>(skin_buf.data());
@@ -265,6 +657,7 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
if (skin.magic == MAGIC_SKIN) { if (skin.magic == MAGIC_SKIN) {
const uint16_t *skin_idx = safe_array<uint16_t>(skin_buf, skin.ofsIndices, skin.nIndices); const uint16_t *skin_idx = safe_array<uint16_t>(skin_buf, skin.ofsIndices, skin.nIndices);
const uint16_t *skin_tri = safe_array<uint16_t>(skin_buf, skin.ofsTriangles, skin.nTriangles); const uint16_t *skin_tri = safe_array<uint16_t>(skin_buf, skin.ofsTriangles, skin.nTriangles);
const uint8_t *skin_bones = safe_array<uint8_t>(skin_buf, skin.ofsProperties, skin.nProperties * 4);
const SkinSubMesh *sms = safe_array<SkinSubMesh> (skin_buf, skin.ofsSubMeshes, skin.nSubMeshes); const SkinSubMesh *sms = safe_array<SkinSubMesh> (skin_buf, skin.ofsSubMeshes, skin.nSubMeshes);
const SkinTextureUnit *tu = safe_array<SkinTextureUnit>(skin_buf, skin.ofsTextureUnits, skin.nTextureUnits); const SkinTextureUnit *tu = safe_array<SkinTextureUnit>(skin_buf, skin.ofsTextureUnits, skin.nTextureUnits);
@@ -303,7 +696,113 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
batch["index_count"] = idx_count; batch["index_count"] = idx_count;
batch["material_id"] = (int)tu[u].materialIndex; batch["material_id"] = (int)tu[u].materialIndex;
batch["texture_combo_index"] = (int)tu[u].textureComboIndex; 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); 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 { } else {
@@ -315,18 +814,81 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
to_godot(skin_path)); 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<M2Sequence>(buf, hdr.ofsAnimations, hdr.nAnimations);
const M2CompBone *bones = safe_array<M2CompBone>(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; Dictionary result;
result["textures"] = textures; result["textures"] = textures;
result["texture_types"] = texture_types; result["texture_types"] = texture_types;
result["texture_flags"] = texture_flags; result["texture_flags"] = texture_flags;
result["materials"] = materials; result["materials"] = materials;
result["texture_combos"] = texture_combos; 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["model_path"] = to_godot(path);
result["vertices"] = vertices; result["vertices"] = vertices;
result["normals"] = normals; result["normals"] = normals;
result["uvs"] = uvs; result["uvs"] = uvs;
result["uvs2"] = uvs2;
result["indices"] = indices; result["indices"] = indices;
result["batches"] = batches; 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; return result;
} }
@@ -340,9 +902,20 @@ Dictionary M2Loader::load_m2(const String &path) {
UtilityFunctions::push_error("M2Loader: cannot read ", path); UtilityFunctions::push_error("M2Loader: cannot read ", path);
return Dictionary(); 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() { void M2Loader::_bind_methods() {
ClassDB::bind_method(D_METHOD("load_m2", "path"), &M2Loader::load_m2); ClassDB::bind_method(D_METHOD("load_m2", "path"), &M2Loader::load_m2);
ClassDB::bind_method(D_METHOD("load_m2_animated", "path"), &M2Loader::load_m2_animated);
} }
+9 -1
View File
@@ -28,10 +28,14 @@ namespace godot {
// "texture_flags": PackedInt32Array, # M2Texture.flags per texture slot // "texture_flags": PackedInt32Array, # M2Texture.flags per texture slot
// "materials": Array[Dictionary], # [{flags, blend_mode}] // "materials": Array[Dictionary], # [{flags, blend_mode}]
// "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures // "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures
// "texture_coord_combos": PackedInt32Array,
// "texture_combiner_combos": PackedInt32Array,
// "m2_flags": int,
// "model_path": String, # absolute source .m2 path // "model_path": String, # absolute source .m2 path
// "vertices": PackedVector3Array, # Godot-space positions // "vertices": PackedVector3Array, # Godot-space positions
// "normals": PackedVector3Array, // "normals": PackedVector3Array,
// "uvs": PackedVector2Array, // "uvs": PackedVector2Array,
// "uvs2": PackedVector2Array,
// "indices": PackedInt32Array, # flat triangle list into vertex array // "indices": PackedInt32Array, # flat triangle list into vertex array
// "batches": Array[Dictionary], # render batches // "batches": Array[Dictionary], # render batches
// } // }
@@ -41,6 +45,9 @@ namespace godot {
// "index_start": int, # offset into indices array // "index_start": int, # offset into indices array
// "index_count": int, # number of indices for this batch // "index_count": int, # number of indices for this batch
// "material_id": int, # index into materials // "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 // "texture_combo_index": int, # index into texture_combos → texture
// } // }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -49,12 +56,13 @@ class M2Loader : public RefCounted {
public: public:
Dictionary load_m2(const String &path); Dictionary load_m2(const String &path);
Dictionary load_m2_animated(const String &path);
protected: protected:
static void _bind_methods(); static void _bind_methods();
private: private:
Dictionary parse_m2(const std::vector<uint8_t> &buf, const std::string &path); Dictionary parse_m2(const std::vector<uint8_t> &buf, const std::string &path, bool include_animation);
static std::vector<uint8_t> read_file(const std::string &path); static std::vector<uint8_t> read_file(const std::string &path);
static std::string to_std(const String &s); static std::string to_std(const String &s);
+22
View File
@@ -2,6 +2,7 @@
#include "wow_chunk_reader.h" #include "wow_chunk_reader.h"
#include <godot_cpp/core/class_db.hpp> #include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/color.hpp>
#include <godot_cpp/variant/utility_functions.hpp> #include <godot_cpp/variant/utility_functions.hpp>
#include <godot_cpp/variant/packed_string_array.hpp> #include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/quaternion.hpp> #include <godot_cpp/variant/quaternion.hpp>
@@ -141,6 +142,22 @@ String WMOLoader::to_godot(const std::string &s) {
return String(s.c_str()); 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 // Root parser
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -180,6 +197,10 @@ Dictionary WMOLoader::parse_root(const std::vector<uint8_t> &buf) {
mat["flags"] = (int)mt[i].flags; mat["flags"] = (int)mt[i].flags;
mat["shader"] = (int)mt[i].shader; mat["shader"] = (int)mt[i].shader;
mat["blend_mode"] = (int)mt[i].blendMode; 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 // texUnit offsets → find index in tex_strings
auto tex_idx = [&](uint32_t ofs) -> int { auto tex_idx = [&](uint32_t ofs) -> int {
uint32_t cur = 0; uint32_t cur = 0;
@@ -191,6 +212,7 @@ Dictionary WMOLoader::parse_root(const std::vector<uint8_t> &buf) {
}; };
mat["texture0"] = tex_idx(mt[i].texUnit0); mat["texture0"] = tex_idx(mt[i].texUnit0);
mat["texture1"] = tex_idx(mt[i].texUnit1); mat["texture1"] = tex_idx(mt[i].texUnit1);
mat["texture2"] = tex_idx(mt[i].texUnit2);
materials.push_back(mat); materials.push_back(mat);
} }
} else if (chunk.is("MODS")) { } else if (chunk.is("MODS")) {
+4
View File
@@ -57,8 +57,12 @@ namespace godot {
// { // {
// "texture0": int, # index into textures array (-1 = none) // "texture0": int, # index into textures array (-1 = none)
// "texture1": int, // "texture1": int,
// "texture2": int,
// "blend_mode": int, # 0=opaque 1=alpha key 2=alpha blend // "blend_mode": int, # 0=opaque 1=alpha key 2=alpha blend
// "flags": int, // "flags": int,
// "flags2": int,
// "diffuse_color": Color,
// "emissive_color": Color,
// } // }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
class WMOLoader : public RefCounted { class WMOLoader : public RefCounted {
+1 -1
View File
@@ -1,7 +1,7 @@
extends Resource extends Resource
class_name WMOStreamingResource class_name WMOStreamingResource
const FORMAT_VERSION := 1 const FORMAT_VERSION := 2
@export var format_version: int = FORMAT_VERSION @export var format_version: int = FORMAT_VERSION
@export var source_path: String = "" @export var source_path: String = ""
+654
View File
@@ -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)
@@ -0,0 +1 @@
uid://cwvvg0npx5vlr
@@ -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")
@@ -181,7 +181,7 @@ func _apply_variant(cat: String, wanted: int) -> void:
for child in _geoset_nodes(): for child in _geoset_nodes():
var gid := _geoset_id(child.name) var gid := _geoset_id(child.name)
if _category(gid) == cat: 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. ## Show or hide all geosets in a category.
func _apply_toggle(cat: String, on: bool) -> void: func _apply_toggle(cat: String, on: bool) -> void:
@@ -204,7 +204,7 @@ func _apply_all(nodes: Array[Node] = []) -> void:
"facial2": child.visible = (gid == facial2) "facial2": child.visible = (gid == facial2)
"facial3": child.visible = (gid == facial3) "facial3": child.visible = (gid == facial3)
"ears": child.visible = (gid == ears) "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 "gloves": child.visible = show_gloves
"boots": child.visible = show_boots "boots": child.visible = show_boots
"shirt": child.visible = show_shirt "shirt": child.visible = show_shirt
@@ -301,17 +301,19 @@ func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void:
if not is_eye_glow: if not is_eye_glow:
continue continue
# Replace every surface material with the glow shader if not _geoset_renderable(mesh_inst, "eye_effects"):
for si in range(mesh_inst.get_surface_override_material_count()): mesh_inst.visible = false
var orig_mat := mesh_inst.mesh.surface_get_material(si) 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() var shader_mat := ShaderMaterial.new()
shader_mat.shader = EYE_GLOW_SHADER 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_color", Vector3(glow_color.r, glow_color.g, glow_color.b))
shader_mat.set_shader_parameter("glow_intensity", eye_glow_intensity) 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 var std := orig_mat as StandardMaterial3D
if std.albedo_texture:
shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture) shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture)
mesh_inst.set_surface_override_material(si, shader_mat) mesh_inst.set_surface_override_material(si, shader_mat)
@@ -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). ## 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 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. ## Clamp/snap v to the nearest available geoset ID in cat.
## Returns v unchanged if _available is not populated yet or cat is missing. ## Returns v unchanged if _available is not populated yet or cat is missing.
func _clamp_geoset(cat: String, v: int) -> int: func _clamp_geoset(cat: String, v: int) -> int:
@@ -357,3 +387,31 @@ func _clamp_geoset(cat: String, v: int) -> int:
best_dist = d best_dist = d
best = id best = id
return best 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
@@ -20,6 +20,16 @@ const NAKED_TORSO_POS := Vector2i(0, 0)
const NAKED_PELVIS_POS := Vector2i(0, 128) const NAKED_PELVIS_POS := Vector2i(0, 128)
const FACE_UPPER_POS := Vector2i(0, 320) const FACE_UPPER_POS := Vector2i(0, 320)
const FACE_LOWER_POS := Vector2i(0, 384) 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 ────────────────────────────────────────────────────────────────── # ── Exports ──────────────────────────────────────────────────────────────────
@@ -27,6 +37,9 @@ const FACE_LOWER_POS := Vector2i(0, 384)
## Example: "res://src/resources/characters/BloodElf/Female/BloodElfFemale_textures" ## Example: "res://src/resources/characters/BloodElf/Female/BloodElfFemale_textures"
@export var textures_dir: String = "" : set = _set_textures_dir @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 / ## Skin colour index (selects skin_XX.png / naked_torso_XX.png /
## naked_pelvis_XX.png). ## naked_pelvis_XX.png).
@export var skin_color: int = 0 : set = _set_skin_color @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} # MeshInstance3D surfaces to override: Array of {node, surface_idx}
var _skin_surfaces: Array = [] var _skin_surfaces: Array = []
var _equipment_components := PackedStringArray()
var _blp_image_cache: Dictionary = {}
# True once _ready has run (prevents setters from firing before init) # True once _ready has run (prevents setters from firing before init)
var _ready_done := false var _ready_done := false
@@ -111,7 +126,7 @@ func _scan_skin_surfaces() -> void:
## A surface is a "skin surface" if: ## A surface is a "skin surface" if:
## - its active material is a StandardMaterial3D (not ShaderMaterial = eye glow) ## - 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: func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
# Prefer surface override, fall back to mesh material # Prefer surface override, fall back to mesh material
var mat := mesh_inst.get_surface_override_material(si) 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 var tex := std.albedo_texture
if tex == null: if tex == null:
return false return false
# Skin texture is 512x512; hair/other textures are smaller
var img := tex.get_image() var img := tex.get_image()
if img == null: if img == null:
return false 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 ────────────────────────────────────────────────────────────── # ── Compositing ──────────────────────────────────────────────────────────────
@@ -151,13 +165,20 @@ func _build_skin_texture() -> ImageTexture:
return null return null
# 2. Naked overlays (underwear) # 2. Naked overlays (underwear)
_blit(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS) _blit_scaled(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_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)
# 3. Face # 3. Face
_blit(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS) _blit_scaled(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_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) return ImageTexture.create_from_image(base)
@@ -184,10 +205,15 @@ func _apply_texture(tex: ImageTexture) -> void:
func _load_layer(filename: String) -> Image: func _load_layer(filename: String) -> Image:
var path := textures_dir.path_join(filename) 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 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 return null
var img := Image.load_from_file(path)
return img
func _blit(dst: Image, src: Image, pos: Vector2i) -> void: 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) 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]: func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = [] var result: Array[MeshInstance3D] = []
_collect_meshes(root, result) _collect_meshes(root, result)
@@ -217,6 +295,11 @@ func refresh() -> void:
_recomposite() _recomposite()
func set_equipment_components(components: PackedStringArray) -> void:
_equipment_components = components
_recomposite()
## Returns the number of skin surfaces found (useful for debugging). ## Returns the number of skin surfaces found (useful for debugging).
func get_skin_surface_count() -> int: func get_skin_surface_count() -> int:
return _skin_surfaces.size() return _skin_surfaces.size()
@@ -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()
@@ -0,0 +1 @@
uid://d3e8tcysnsliv
@@ -1,5 +1,9 @@
extends CharacterBody3D 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 TILE_SIZE := 533.33333
const CHUNK_SIZE := TILE_SIZE / 16.0 const CHUNK_SIZE := TILE_SIZE / 16.0
const UNIT_SIZE := CHUNK_SIZE / 8.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 backward_speed: float = 4.5
@export var strafe_speed: float = 4.5 @export var strafe_speed: float = 4.5
@export var sprint_multiplier: float = 6.0 @export var sprint_multiplier: float = 6.0
@export var flight_vertical_speed: float = 7.0
@export var acceleration: float = 28.0 @export var acceleration: float = 28.0
@export var mouse_sensitivity: float = 0.003 @export var mouse_sensitivity: float = 0.003
@export var camera_pitch_min: float = deg_to_rad(-65.0) @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_pitch_max: float = deg_to_rad(35.0)
@export var camera_height: float = 1.7 @export var camera_height: float = 1.7
@export var camera_distance: float = 8.0 @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_offset: float = 0.05
@export var ground_snap_speed: float = 24.0 @export var ground_snap_speed: float = 24.0
@export var camera_pivot_path: NodePath = NodePath("CameraPivot") @export var camera_pivot_path: NodePath = NodePath("CameraPivot")
@export var camera_path: NodePath = NodePath("CameraPivot/Camera3D") @export var camera_path: NodePath = NodePath("CameraPivot/Camera3D")
@export var visual_path: NodePath = NodePath("Visual") @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_pivot: Node3D
var _camera: Camera3D var _camera: Camera3D
var _visual: Node3D var _visual: Node3D
var _character_root: Node3D
var _animation_player: AnimationPlayer
var _active_animation := ""
var _captured := false var _captured := false
var _yaw := 0.0 var _yaw := 0.0
var _pitch := deg_to_rad(-18.0) var _pitch := deg_to_rad(-18.0)
var _horizontal_velocity := Vector3.ZERO var _horizontal_velocity := Vector3.ZERO
var _flight_enabled := false
var _adt_cache: Dictionary = {} var _adt_cache: Dictionary = {}
@@ -47,6 +64,7 @@ func _ready() -> void:
if spawn_at_tile_center: if spawn_at_tile_center:
global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE
global_position.z = (float(spawn_tile_y) + 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) var ground := _sample_ground_height(global_position)
if is_finite(ground): if is_finite(ground):
global_position.y = ground + ground_offset 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: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
_captured = event.pressed _captured = event.pressed
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE 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: elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_captured = false _captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE 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: if _captured and event is InputEventMouseMotion:
_yaw -= event.relative.x * mouse_sensitivity _yaw -= event.relative.x * mouse_sensitivity
@@ -74,18 +101,24 @@ func _physics_process(delta: float) -> void:
var target_velocity := Vector3.ZERO var target_velocity := Vector3.ZERO
if input_dir.length_squared() > 0.0: if input_dir.length_squared() > 0.0:
target_velocity = _movement_vector(input_dir) 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) _horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
global_position += _horizontal_velocity * delta global_position += _horizontal_velocity * delta
if not _flight_enabled:
var ground := _sample_ground_height(global_position) var ground := _sample_ground_height(global_position)
if is_finite(ground): if is_finite(ground):
var target_y := ground + ground_offset var target_y := ground + ground_offset
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0)) 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) _visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z)
_update_character_animation(horizontal_motion.length_squared() > 0.04)
_apply_camera_transform() _apply_camera_transform()
@@ -105,6 +138,10 @@ func _get_input_dir() -> Vector2:
func _movement_vector(input_dir: Vector2) -> Vector3: func _movement_vector(input_dir: Vector2) -> Vector3:
var forward := -global_basis.z var forward := -global_basis.z
var right := global_basis.x var right := global_basis.x
if _flight_enabled and _camera_pivot:
forward = -_camera_pivot.global_basis.z
right = _camera_pivot.global_basis.x
else:
forward.y = 0.0 forward.y = 0.0
right.y = 0.0 right.y = 0.0
forward = forward.normalized() forward = forward.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 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: func _apply_camera_transform() -> void:
if _camera_pivot: if _camera_pivot:
_camera_pivot.position = Vector3(0.0, camera_height, 0.0) _camera_pivot.position = Vector3(0.0, camera_height, 0.0)
+51
View File
@@ -95,11 +95,13 @@ var _active_skybox_node: Node3D
var _last_logged_area_id := -1 var _last_logged_area_id := -1
var _last_logged_zone_id := -1 var _last_logged_zone_id := -1
var _last_logged_profile_signature := "" var _last_logged_profile_signature := ""
var _last_global_light_signature := ""
func _ready() -> void: func _ready() -> void:
_resolve_nodes() _resolve_nodes()
_prepare_environment() _prepare_environment()
_ensure_wow_shader_globals()
_loaded = _load_lighting_dbcs() _loaded = _load_lighting_dbcs()
if _loaded: if _loaded:
print("WowSkyController: loaded %d LightParams profiles, %d area records and %d %s light volumes" % [ 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) _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) _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: func _load_lighting_dbcs() -> bool:
var base := _res_path(extracted_dir).path_join("DBFilesClient") 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 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: func _get_light_volume_count(id: int) -> int:
return int((_light_volumes_by_map.get(id, []) as Array).size()) return int((_light_volumes_by_map.get(id, []) as Array).size())
@@ -61,6 +61,8 @@ max_concurrent_tile_tasks = 1
tile_lod_remove_ops_per_tick = 1 tile_lod_remove_ops_per_tick = 1
m2_build_groups_per_tick = 1 m2_build_groups_per_tick = 1
m2_multimesh_batch_size = 64 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 wmo_render_group_ops_per_tick = 16
cached_tile_mesh_limit = 48 cached_tile_mesh_limit = 48
terrain_quality_mesh_cache_limit = 48 terrain_quality_mesh_cache_limit = 48
@@ -85,13 +87,14 @@ m2_tile_radius = 3
wmo_tile_radius = 5 wmo_tile_radius = 5
m2_visibility_range = 1600.0 m2_visibility_range = 1600.0
wmo_visibility_range = 3600.0 wmo_visibility_range = 3600.0
debug_streaming = true
hitch_profiler_enabled = true hitch_profiler_enabled = true
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] [node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 22666, 80, 15200) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_player") script = ExtResource("2_player")
spawn_tile_x = 31 spawn_tile_x = 31
spawn_tile_y = 31 spawn_tile_y = 49
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer" unique_id=1297880621] [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) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
+245
View File
@@ -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()
@@ -0,0 +1 @@
uid://b8pxshlr85g2t
File diff suppressed because it is too large Load Diff
Binary file not shown.
+81 -7
View File
@@ -3,6 +3,8 @@
## Usage: ## Usage:
## godot --headless --path <project> --script res://src/tools/bake_m2_cache.gd -- \ ## godot --headless --path <project> --script res://src/tools/bake_m2_cache.gd -- \
## --map Azeroth --extracted res://data/extracted --output res://data/cache/m2_glb --force ## --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 extends SceneTree
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
@@ -13,6 +15,10 @@ func _initialize() -> void:
var extracted := _res(_arg(args, "--extracted", "res://data/extracted")) var extracted := _res(_arg(args, "--extracted", "res://data/extracted"))
var output_dir := _res(_arg(args, "--output", "res://data/cache/m2_glb")) var output_dir := _res(_arg(args, "--output", "res://data/cache/m2_glb"))
var force := args.has("--force") 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"): if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("M2Loader"):
push_error("GDExtension not loaded. Rebuild first.") push_error("GDExtension not loaded. Rebuild first.")
@@ -47,7 +53,7 @@ func _initialize() -> void:
var norm := str(rel).replace("\\", "/").to_lower() var norm := str(rel).replace("\\", "/").to_lower()
if norm.ends_with(".mdx") or norm.ends_with(".mdl"): if norm.ends_with(".mdx") or norm.ends_with(".mdl"):
norm = norm.get_basename() + ".m2" 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 unique[norm] = true
scanned += 1 scanned += 1
@@ -55,8 +61,10 @@ func _initialize() -> void:
var m2_loader = ClassDB.instantiate("M2Loader") var m2_loader = ClassDB.instantiate("M2Loader")
var baked := 0 var baked := 0
var baked_glb := 0
var skipped := 0 var skipped := 0
var failed := 0 var failed := 0
var failed_glb := 0
var total := unique.size() var total := unique.size()
var i := 0 var i := 0
@@ -64,16 +72,22 @@ func _initialize() -> void:
i += 1 i += 1
var stem: String = rel_path.get_basename() var stem: String = rel_path.get_basename()
var out_path := output_dir.path_join(stem + ".tscn") var out_path := output_dir.path_join(stem + ".tscn")
var out_glb_path := output_dir.path_join(stem + ".glb")
if not force and ResourceLoader.exists(out_path):
skipped += 1
continue
var abs_m2 := ProjectSettings.globalize_path(extracted.path_join(rel_path)) var abs_m2 := ProjectSettings.globalize_path(extracted.path_join(rel_path))
if not FileAccess.file_exists(abs_m2): if not FileAccess.file_exists(abs_m2):
failed += 1 failed += 1
continue 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) var data: Dictionary = m2_loader.call("load_m2", abs_m2)
if data.is_empty() or data.get("vertices", PackedVector3Array()).is_empty(): if data.is_empty() or data.get("vertices", PackedVector3Array()).is_empty():
failed += 1 failed += 1
@@ -102,10 +116,17 @@ func _initialize() -> void:
continue continue
baked += 1 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: 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) quit(0)
@@ -116,12 +137,65 @@ func _arg(args: PackedStringArray, name: String, default: String) -> String:
return default 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: func _res(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"): if path.begins_with("res://") or path.begins_with("user://"):
return path return path
return "res://" + path.trim_prefix("./").trim_prefix("/") 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: func _set_owner_recursive(node: Node, owner_root: Node) -> void:
for child in node.get_children(): for child in node.get_children():
child.owner = owner_root child.owner = owner_root
+29 -28
View File
@@ -650,7 +650,11 @@ class SkinParser:
class GltfBuilder: class GltfBuilder:
def __init__(self): 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.nodes = []
self.meshes = [] self.meshes = []
self.skins = [] self.skins = []
@@ -1027,36 +1031,41 @@ class GltfBuilder:
def _build_skeleton(self, m2: M2Parser): def _build_skeleton(self, m2: M2Parser):
bone_node_indices = [] bone_node_indices = []
pivot_node_indices = []
for i, bone in enumerate(m2.bones): for i, bone in enumerate(m2.bones):
node_idx = len(self.nodes) pivot_idx = len(self.nodes)
bone_node_indices.append(node_idx) 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']) px, py, pz = wow_pos_to_gltf(*bone['pivot'])
self.nodes.append({
node = { "name": f"bone_{i}_pivot",
"name": f"bone_{i}",
"translation": [px, py, pz], "translation": [px, py, pz],
"rotation": [0.0, 0.0, 0.0, 1.0], "children": [anim_idx],
"scale": [1.0, 1.0, 1.0], })
} self.nodes.append({
self.nodes.append(node) "name": f"bone_{i}",
})
# Wire up children # Wire pivot nodes into the hierarchy.
for i, bone in enumerate(m2.bones): for i, bone in enumerate(m2.bones):
parent = bone['parent'] parent = bone['parent']
if parent >= 0 and parent < len(m2.bones): if parent >= 0 and parent < len(m2.bones):
parent_node = bone_node_indices[parent] 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) 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): for i, bone in enumerate(m2.bones):
parent = bone['parent'] parent = bone['parent']
if parent >= 0 and parent < len(m2.bones): if parent >= 0 and parent < len(m2.bones):
ppx, ppy, ppz = wow_pos_to_gltf(*m2.bones[parent]['pivot']) 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"] t = node["translation"]
node["translation"] = [t[0]-ppx, t[1]-ppy, t[2]-ppz] 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 # WoW models have multiple root bones (main skeleton + attachment/socket
# bones each with parent=-1). Wrap them all under a single virtual # 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. # "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] for i, b in enumerate(m2.bones) if b['parent'] < 0]
armature_idx = len(self.nodes) armature_idx = len(self.nodes)
self.nodes.append({ self.nodes.append({
@@ -1310,20 +1319,12 @@ class GltfBuilder:
node_idx = bone_node_indices[bone_idx] node_idx = bone_node_indices[bone_idx]
# Translation # Translation
# M2 t_track values are OFFSETS from the bone's rest position (pivot # Pivot offset lives on the parent pivot node. The animated bone node
# relative to parent pivot). GLTF animation overwrites node.translation # receives only the M2 translation offset.
# entirely, so we must emit: rest_local + m2_offset (both in GLTF space).
ts, vals = m2._resolve_track(bone['t_track'], seq_idx, 'vec3') ts, vals = m2._resolve_track(bone['t_track'], seq_idx, 'vec3')
if ts and vals: if ts and vals:
rest = self.nodes[node_idx].get("translation", [0.0, 0.0, 0.0]) s_idx = self._anim_sampler(ts, vals, 'VEC3',
rx, ry, rz = rest[0], rest[1], rest[2] lambda v: wow_pos_to_gltf(*v))
# 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))
if s_idx is not None: if s_idx is not None:
samplers.append(s_idx) samplers.append(s_idx)
channels.append({"sampler": len(samplers)-1, channels.append({"sampler": len(samplers)-1,