победа над водопадами, шейдеры 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
+576 -3
View File
@@ -6,10 +6,17 @@
#include <godot_cpp/variant/packed_vector3_array.hpp>
#include <godot_cpp/variant/packed_vector2_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 <cstring>
#include <cstdio>
#include <cmath>
#include <algorithm>
#include <cstdint>
#include <cctype>
using namespace godot;
@@ -54,6 +61,38 @@ struct M2Header {
uint32_t ofsBoneCombos;
uint32_t nTextureCombos; // offset 128
uint32_t ofsTextureCombos; // offset 132
uint32_t nTextureCoordCombos;
uint32_t ofsTextureCoordCombos;
uint32_t nTextureWeightCombos;
uint32_t ofsTextureWeightCombos;
uint32_t nTextureTransformCombos;
uint32_t ofsTextureTransformCombos;
float bounds[7];
float collisionBounds[7];
uint32_t nCollisionIndices;
uint32_t ofsCollisionIndices;
uint32_t nCollisionPositions;
uint32_t ofsCollisionPositions;
uint32_t nCollisionFaceNormals;
uint32_t ofsCollisionFaceNormals;
uint32_t nAttachments;
uint32_t ofsAttachments;
uint32_t nAttachmentLookup;
uint32_t ofsAttachmentLookup;
uint32_t nEvents;
uint32_t ofsEvents;
uint32_t nLights;
uint32_t ofsLights;
uint32_t nCameras;
uint32_t ofsCameras;
uint32_t nCameraLookup;
uint32_t ofsCameraLookup;
uint32_t nRibbonEmitters;
uint32_t ofsRibbonEmitters;
uint32_t nParticleEmitters;
uint32_t ofsParticleEmitters;
uint32_t nTextureCombinerCombos;
uint32_t ofsTextureCombinerCombos;
};
// Total: 34 × 4 = 136 bytes
@@ -79,6 +118,70 @@ struct M2Material {
uint16_t blendingMode; // 0=opaque 1=alpha_key 2=alpha_blend
};
struct M2ArrayRef {
uint32_t count;
uint32_t offset;
};
struct M2Track {
uint16_t interpolationType;
uint16_t globalSequence;
M2ArrayRef timestamps;
M2ArrayRef values;
};
struct M2Color {
M2Track colorTrack;
M2Track alphaTrack;
};
struct M2TextureTransform {
M2Track translationTrack;
M2Track rotationTrack;
M2Track scaleTrack;
};
struct M2TextureWeight {
M2Track weightTrack;
};
struct M2CompBone {
uint32_t boneId;
uint32_t flags;
uint16_t parentIndex;
uint16_t submeshId;
uint32_t unknown;
M2Track translation;
M2Track rotation;
M2Track scale;
float pivot[3];
};
struct M2Sequence {
uint16_t id;
uint16_t variationIndex;
uint32_t duration;
float moveSpeed;
uint32_t flags;
uint32_t frequency;
uint32_t replayMin;
uint32_t replayMax;
uint32_t blendTime;
float minBounds[3];
float maxBounds[3];
float boundsRadius;
uint16_t nextAnimation;
uint16_t aliasNext;
};
struct M2Float3 {
float v[3];
};
struct M2CompQuat {
uint16_t v[4];
};
struct SkinHeader {
uint32_t magic; // 'SKIN' LE = 0x4E494B53
uint32_t nIndices;
@@ -164,10 +267,203 @@ static const T *safe_array(const std::vector<uint8_t> &buf, uint32_t ofs, uint32
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
// ─────────────────────────────────────────────────────────────────────────────
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();
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 ─────────────────────────────────────────────────────────────
PackedVector3Array vertices, normals;
PackedVector2Array uvs;
PackedVector2Array uvs, uvs2;
const auto *verts = safe_array<M2Vertex>(buf, hdr.ofsVertices, hdr.nVertices);
if (verts && hdr.nVertices > 0) {
vertices.resize(hdr.nVertices);
normals.resize(hdr.nVertices);
uvs.resize(hdr.nVertices);
uvs2.resize(hdr.nVertices);
for (uint32_t i = 0; i < hdr.nVertices; ++i) {
const auto &v = verts[i];
// WoW model space (X right, Y forward, Z up) → Godot (X right, Y up, Z back)
@@ -194,6 +491,7 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
// (D3D/Vulkan convention) — pass UVs through unchanged. Same as
// wmo_loader's MOTV handling.
uvs[i] = Vector2(v.texCoords[0], v.texCoords[1]);
uvs2[i] = Vector2(v.texCoords2[0], v.texCoords2[1]);
}
}
@@ -244,7 +542,92 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
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 ────────────────────────────────────────────────────────────
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
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);
PackedInt32Array indices;
PackedInt32Array bone_lookup_table;
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)) {
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) {
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 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 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["material_id"] = (int)tu[u].materialIndex;
batch["texture_combo_index"] = (int)tu[u].textureComboIndex;
batch["texture_count"] = (int)tu[u].textureCount;
batch["shader_id"] = (int)tu[u].shaderID;
batch["texture_coord_combo_index"] = (int)tu[u].textureCoordComboIndex;
batch["texture_weight_combo_index"] = (int)tu[u].textureWeightComboIndex;
batch["texture_transform_combo_index"] = (int)tu[u].textureTransformComboIndex;
batch["material_layer"] = (int)tu[u].materialLayer;
batch["color_index"] = (int)tu[u].colorIndex;
batch["priority_plane"] = (int)tu[u].priority;
batch["skin_flags"] = (int)tu[u].flags;
batch["skin_flags2"] = (int)tu[u].flags2;
batch["bone_count"] = (int)sm.boneCount;
batch["bone_combo_index"] = (int)sm.boneStart;
batches.push_back(batch);
if (include_animation && verts) {
PackedVector3Array surface_vertices;
PackedVector3Array surface_normals;
PackedVector2Array surface_uvs, surface_uvs2;
PackedInt32Array surface_bones;
PackedFloat32Array surface_weights;
PackedInt32Array surface_indices;
uint32_t expanded_count = 0;
surface_vertices.resize(tri_count);
surface_normals.resize(tri_count);
surface_uvs.resize(tri_count);
surface_uvs2.resize(tri_count);
surface_bones.resize(tri_count * 4);
surface_weights.resize(tri_count * 4);
surface_indices.resize(tri_count);
for (uint32_t t = 0; t + 2 < tri_count; t += 3) {
uint16_t tri_local[3] = {
skin_tri[tri_start + t],
skin_tri[tri_start + t + 2],
skin_tri[tri_start + t + 1],
};
for (uint32_t corner = 0; corner < 3; ++corner) {
uint16_t vertex_lookup = tri_local[corner];
if (vertex_lookup >= skin.nIndices) {
continue;
}
uint16_t global_vertex_index = skin_idx[vertex_lookup];
if (global_vertex_index >= hdr.nVertices) {
continue;
}
const auto &v = verts[global_vertex_index];
surface_vertices[expanded_count] = Vector3(v.pos[0], v.pos[2], -v.pos[1]);
surface_normals[expanded_count] = Vector3(v.normal[0], v.normal[2], -v.normal[1]);
surface_uvs[expanded_count] = Vector2(v.texCoords[0], v.texCoords[1]);
surface_uvs2[expanded_count] = Vector2(v.texCoords2[0], v.texCoords2[1]);
surface_indices[expanded_count] = (int)expanded_count;
uint32_t weight_sum = 0;
for (uint32_t j = 0; j < 4; ++j) {
weight_sum += (uint32_t)v.boneWeights[j];
}
if (weight_sum == 0) {
weight_sum = 1;
}
for (uint32_t j = 0; j < 4; ++j) {
int local_bone = (int)v.boneIndices[j];
if (skin_bones && vertex_lookup < skin.nProperties) {
local_bone = (int)skin_bones[vertex_lookup * 4 + j];
}
int global_bone = local_bone;
uint32_t lookup_index = (uint32_t)sm.boneStart + (uint32_t)std::max(local_bone, 0);
if (bone_combo_arr && lookup_index < hdr.nBoneCombos) {
global_bone = (int)bone_combo_arr[lookup_index];
}
surface_bones[expanded_count * 4 + j] = global_bone;
surface_weights[expanded_count * 4 + j] = (float)v.boneWeights[j] / (float)weight_sum;
}
expanded_count++;
}
}
if (expanded_count > 0) {
surface_vertices.resize(expanded_count);
surface_normals.resize(expanded_count);
surface_uvs.resize(expanded_count);
surface_uvs2.resize(expanded_count);
surface_bones.resize(expanded_count * 4);
surface_weights.resize(expanded_count * 4);
surface_indices.resize(expanded_count);
Dictionary surface;
surface["vertices"] = surface_vertices;
surface["normals"] = surface_normals;
surface["uvs"] = surface_uvs;
surface["uvs2"] = surface_uvs2;
surface["bones"] = surface_bones;
surface["weights"] = surface_weights;
surface["indices"] = surface_indices;
surface["material_id"] = (int)tu[u].materialIndex;
surface["texture_combo_index"] = (int)tu[u].textureComboIndex;
surface["texture_count"] = (int)tu[u].textureCount;
surface["shader_id"] = (int)tu[u].shaderID;
surface["texture_coord_combo_index"] = (int)tu[u].textureCoordComboIndex;
surface["texture_weight_combo_index"] = (int)tu[u].textureWeightComboIndex;
surface["texture_transform_combo_index"] = (int)tu[u].textureTransformComboIndex;
surface["material_layer"] = (int)tu[u].materialLayer;
surface["color_index"] = (int)tu[u].colorIndex;
surface["bone_count"] = (int)sm.boneCount;
surface["bone_combo_index"] = (int)sm.boneStart;
animated_surfaces.push_back(surface);
}
}
}
}
} else {
@@ -315,18 +814,81 @@ Dictionary M2Loader::parse_m2(const std::vector<uint8_t> &buf, const std::string
to_godot(skin_path));
}
Array animated_bones;
Array animated_sequences;
int animation_sequence_index = -1;
float animation_length = 0.0f;
int animation_id = -1;
uint32_t animation_activity_score = 0;
if (include_animation) {
const M2Sequence *seqs = safe_array<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;
result["textures"] = textures;
result["texture_types"] = texture_types;
result["texture_flags"] = texture_flags;
result["materials"] = materials;
result["texture_combos"] = texture_combos;
result["texture_coord_combos"] = texture_coord_combos;
result["texture_weight_combos"] = texture_weight_combos;
result["texture_transform_combos"] = texture_transform_combos;
result["texture_combiner_combos"] = texture_combiner_combos;
result["m2_colors"] = m2_colors;
result["texture_weights"] = texture_weights;
result["texture_transforms"] = texture_transforms;
result["m2_flags"] = (int)hdr.flags;
result["model_path"] = to_godot(path);
result["vertices"] = vertices;
result["normals"] = normals;
result["uvs"] = uvs;
result["uvs2"] = uvs2;
result["indices"] = indices;
result["batches"] = batches;
if (include_animation) {
result["animated_surfaces"] = animated_surfaces;
result["bone_lookup_table"] = bone_lookup_table;
result["bones"] = animated_bones;
result["sequences"] = animated_sequences;
result["animation_sequence_index"] = animation_sequence_index;
result["animation_id"] = animation_id;
result["animation_length"] = animation_length;
result["animation_activity_score"] = (int)animation_activity_score;
}
return result;
}
@@ -340,9 +902,20 @@ Dictionary M2Loader::load_m2(const String &path) {
UtilityFunctions::push_error("M2Loader: cannot read ", path);
return Dictionary();
}
return parse_m2(buf, spath);
return parse_m2(buf, spath, false);
}
Dictionary M2Loader::load_m2_animated(const String &path) {
std::string spath = to_std(path);
auto buf = read_file(spath);
if (buf.empty()) {
UtilityFunctions::push_error("M2Loader: cannot read ", path);
return Dictionary();
}
return parse_m2(buf, spath, true);
}
void M2Loader::_bind_methods() {
ClassDB::bind_method(D_METHOD("load_m2", "path"), &M2Loader::load_m2);
ClassDB::bind_method(D_METHOD("load_m2_animated", "path"), &M2Loader::load_m2_animated);
}
+9 -1
View File
@@ -28,10 +28,14 @@ namespace godot {
// "texture_flags": PackedInt32Array, # M2Texture.flags per texture slot
// "materials": Array[Dictionary], # [{flags, blend_mode}]
// "texture_combos": PackedInt32Array, # textureCombos[i] = index into textures
// "texture_coord_combos": PackedInt32Array,
// "texture_combiner_combos": PackedInt32Array,
// "m2_flags": int,
// "model_path": String, # absolute source .m2 path
// "vertices": PackedVector3Array, # Godot-space positions
// "normals": PackedVector3Array,
// "uvs": PackedVector2Array,
// "uvs2": PackedVector2Array,
// "indices": PackedInt32Array, # flat triangle list into vertex array
// "batches": Array[Dictionary], # render batches
// }
@@ -41,6 +45,9 @@ namespace godot {
// "index_start": int, # offset into indices array
// "index_count": int, # number of indices for this batch
// "material_id": int, # index into materials
// "texture_count": int,
// "shader_id": int,
// "texture_coord_combo_index": int,
// "texture_combo_index": int, # index into texture_combos → texture
// }
// ─────────────────────────────────────────────────────────────────────────────
@@ -49,12 +56,13 @@ class M2Loader : public RefCounted {
public:
Dictionary load_m2(const String &path);
Dictionary load_m2_animated(const String &path);
protected:
static void _bind_methods();
private:
Dictionary parse_m2(const std::vector<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::string to_std(const String &s);
+22
View File
@@ -2,6 +2,7 @@
#include "wow_chunk_reader.h"
#include <godot_cpp/core/class_db.hpp>
#include <godot_cpp/variant/color.hpp>
#include <godot_cpp/variant/utility_functions.hpp>
#include <godot_cpp/variant/packed_string_array.hpp>
#include <godot_cpp/variant/quaternion.hpp>
@@ -141,6 +142,22 @@ String WMOLoader::to_godot(const std::string &s) {
return String(s.c_str());
}
static Color color_from_bgra_u32(uint32_t value) {
const float b = (value & 0xFFu) / 255.0f;
const float g = ((value >> 8) & 0xFFu) / 255.0f;
const float r = ((value >> 16) & 0xFFu) / 255.0f;
const float a = ((value >> 24) & 0xFFu) / 255.0f;
return Color(r, g, b, a);
}
static Color color_from_rgba_u32(uint32_t value) {
const float r = (value & 0xFFu) / 255.0f;
const float g = ((value >> 8) & 0xFFu) / 255.0f;
const float b = ((value >> 16) & 0xFFu) / 255.0f;
const float a = ((value >> 24) & 0xFFu) / 255.0f;
return Color(r, g, b, a);
}
// ─────────────────────────────────────────────────────────────────────────────
// Root parser
// ─────────────────────────────────────────────────────────────────────────────
@@ -180,6 +197,10 @@ Dictionary WMOLoader::parse_root(const std::vector<uint8_t> &buf) {
mat["flags"] = (int)mt[i].flags;
mat["shader"] = (int)mt[i].shader;
mat["blend_mode"] = (int)mt[i].blendMode;
mat["emissive_color"] = color_from_bgra_u32(mt[i].sidnColor);
mat["diffuse_color"] = color_from_rgba_u32(mt[i].diffColor);
mat["color2"] = color_from_rgba_u32(mt[i].color2);
mat["flags2"] = (int)mt[i].flags2;
// texUnit offsets → find index in tex_strings
auto tex_idx = [&](uint32_t ofs) -> int {
uint32_t cur = 0;
@@ -191,6 +212,7 @@ Dictionary WMOLoader::parse_root(const std::vector<uint8_t> &buf) {
};
mat["texture0"] = tex_idx(mt[i].texUnit0);
mat["texture1"] = tex_idx(mt[i].texUnit1);
mat["texture2"] = tex_idx(mt[i].texUnit2);
materials.push_back(mat);
}
} else if (chunk.is("MODS")) {
+4
View File
@@ -57,8 +57,12 @@ namespace godot {
// {
// "texture0": int, # index into textures array (-1 = none)
// "texture1": int,
// "texture2": int,
// "blend_mode": int, # 0=opaque 1=alpha key 2=alpha blend
// "flags": int,
// "flags2": int,
// "diffuse_color": Color,
// "emissive_color": Color,
// }
// ─────────────────────────────────────────────────────────────────────────────
class WMOLoader : public RefCounted {
+1 -1
View File
@@ -1,7 +1,7 @@
extends Resource
class_name WMOStreamingResource
const FORMAT_VERSION := 1
const FORMAT_VERSION := 2
@export var format_version: int = FORMAT_VERSION
@export var source_path: String = ""
+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():
var gid := _geoset_id(child.name)
if _category(gid) == cat:
child.visible = (gid == wanted)
child.visible = (gid == wanted) and _geoset_renderable(child, cat)
## Show or hide all geosets in a category.
func _apply_toggle(cat: String, on: bool) -> void:
@@ -204,7 +204,7 @@ func _apply_all(nodes: Array[Node] = []) -> void:
"facial2": child.visible = (gid == facial2)
"facial3": child.visible = (gid == facial3)
"ears": child.visible = (gid == ears)
"eye_effects": child.visible = (gid == eye_effects)
"eye_effects": child.visible = (gid == eye_effects) and _geoset_renderable(child, cat)
"gloves": child.visible = show_gloves
"boots": child.visible = show_boots
"shirt": child.visible = show_shirt
@@ -301,18 +301,20 @@ func _apply_eye_glow_shader(nodes: Array[Node] = []) -> void:
if not is_eye_glow:
continue
# Replace every surface material with the glow shader
for si in range(mesh_inst.get_surface_override_material_count()):
var orig_mat := mesh_inst.mesh.surface_get_material(si)
if not _geoset_renderable(mesh_inst, "eye_effects"):
mesh_inst.visible = false
continue
for si in range(mesh_inst.mesh.get_surface_count()):
var orig_mat := _surface_material(mesh_inst, si)
if not _material_has_albedo_texture(orig_mat):
continue
var shader_mat := ShaderMaterial.new()
shader_mat.shader = EYE_GLOW_SHADER
shader_mat.set_shader_parameter("glow_color", Vector3(glow_color.r, glow_color.g, glow_color.b))
shader_mat.set_shader_parameter("glow_intensity", eye_glow_intensity)
# Carry over the original albedo texture if it was a StandardMaterial3D
if orig_mat is StandardMaterial3D:
var std := orig_mat as StandardMaterial3D
if std.albedo_texture:
shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture)
var std := orig_mat as StandardMaterial3D
shader_mat.set_shader_parameter("albedo_texture", std.albedo_texture)
mesh_inst.set_surface_override_material(si, shader_mat)
## Returns sorted list of geoset IDs available for a given category.
@@ -338,6 +340,34 @@ func get_skin_color_count() -> int: return _skin_color_count
## Returns valid face style count for this model (0 if compositor not found yet).
func get_face_style_count() -> int: return _face_style_count
func apply_outfit_defaults(outfit: Dictionary = {}) -> void:
show_gloves = true
show_boots = true
show_shirt = false
show_kneepads = true
show_chest = false
show_pants = false
show_tabard = false
show_legs = (int(outfit.get("flags", 0)) & 0x4) == 0
show_cape = true
show_belt = false
show_feet = false
wristband_style = _clamp_geoset("wristbands", 802)
_apply_exact_or_first("gloves", 401)
_apply_exact_or_first("boots", 501)
_apply_exact_or_first("ears", 702)
_apply_exact_or_first("wristbands", wristband_style)
_apply_exact_or_first("kneepads", 902)
_apply_exact_or_first("legs", 1301)
_apply_exact_or_first("cape", 1501)
_apply_toggle("shirt", false)
_apply_toggle("chest", false)
_apply_toggle("pants", false)
_apply_toggle("tabard", false)
_apply_toggle("belt", false)
_apply_toggle("feet", false)
## Clamp/snap v to the nearest available geoset ID in cat.
## Returns v unchanged if _available is not populated yet or cat is missing.
func _clamp_geoset(cat: String, v: int) -> int:
@@ -357,3 +387,31 @@ func _clamp_geoset(cat: String, v: int) -> int:
best_dist = d
best = id
return best
func _apply_exact_or_first(cat: String, wanted: int) -> void:
var id := _clamp_geoset(cat, wanted)
_apply_variant(cat, id)
func _geoset_renderable(node: Node, cat: String) -> bool:
if cat != "eye_effects":
return true
var mesh_inst := node as MeshInstance3D
if mesh_inst == null or mesh_inst.mesh == null:
return false
for si in range(mesh_inst.mesh.get_surface_count()):
if _material_has_albedo_texture(_surface_material(mesh_inst, si)):
return true
return false
func _surface_material(mesh_inst: MeshInstance3D, surface: int) -> Material:
var mat := mesh_inst.get_surface_override_material(surface)
if mat == null and mesh_inst.mesh != null:
mat = mesh_inst.mesh.surface_get_material(surface)
return mat
func _material_has_albedo_texture(mat: Material) -> bool:
return mat is StandardMaterial3D and (mat as StandardMaterial3D).albedo_texture != null
@@ -20,6 +20,16 @@ const NAKED_TORSO_POS := Vector2i(0, 0)
const NAKED_PELVIS_POS := Vector2i(0, 128)
const FACE_UPPER_POS := Vector2i(0, 320)
const FACE_LOWER_POS := Vector2i(0, 384)
const COMPONENT_RECTS := [
Rect2i(0, 0, 256, 128),
Rect2i(0, 128, 256, 128),
Rect2i(0, 256, 256, 64),
Rect2i(256, 0, 256, 128),
Rect2i(256, 128, 256, 64),
Rect2i(256, 192, 256, 128),
Rect2i(256, 320, 256, 128),
Rect2i(256, 448, 256, 64),
]
# ── Exports ──────────────────────────────────────────────────────────────────
@@ -27,6 +37,9 @@ const FACE_LOWER_POS := Vector2i(0, 384)
## Example: "res://src/resources/characters/BloodElf/Female/BloodElfFemale_textures"
@export var textures_dir: String = "" : set = _set_textures_dir
## Root folder containing extracted WoW data. Used for DBC item component BLPs.
@export var extracted_dir: String = "res://data/extracted"
## Skin colour index (selects skin_XX.png / naked_torso_XX.png /
## naked_pelvis_XX.png).
@export var skin_color: int = 0 : set = _set_skin_color
@@ -38,6 +51,8 @@ const FACE_LOWER_POS := Vector2i(0, 384)
# MeshInstance3D surfaces to override: Array of {node, surface_idx}
var _skin_surfaces: Array = []
var _equipment_components := PackedStringArray()
var _blp_image_cache: Dictionary = {}
# True once _ready has run (prevents setters from firing before init)
var _ready_done := false
@@ -111,7 +126,7 @@ func _scan_skin_surfaces() -> void:
## A surface is a "skin surface" if:
## - its active material is a StandardMaterial3D (not ShaderMaterial = eye glow)
## - AND its albedo texture is 512x512 (the composited body skin)
## - AND its albedo texture is square body texture, usually 256x256 or 512x512
func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
# Prefer surface override, fall back to mesh material
var mat := mesh_inst.get_surface_override_material(si)
@@ -123,11 +138,10 @@ func _is_skin_surface(mesh_inst: MeshInstance3D, si: int) -> bool:
var tex := std.albedo_texture
if tex == null:
return false
# Skin texture is 512x512; hair/other textures are smaller
var img := tex.get_image()
if img == null:
return false
return img.get_width() == 512 and img.get_height() == 512
return img.get_width() == img.get_height() and img.get_width() >= 256
# ── Compositing ──────────────────────────────────────────────────────────────
@@ -151,13 +165,20 @@ func _build_skin_texture() -> ImageTexture:
return null
# 2. Naked overlays (underwear)
_blit(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS)
_blit(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)
_blit_scaled(base, _load_layer("naked_torso_%02d.png" % skin_color), NAKED_TORSO_POS)
_blit_scaled(base, _load_layer("naked_pelvis_%02d.png" % skin_color), NAKED_PELVIS_POS)
# 3. Face
_blit(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS)
_blit(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS)
_blit_scaled(base, _load_layer("face_upper_%02d_%02d.png" % [face_style, skin_color]), FACE_UPPER_POS)
_blit_scaled(base, _load_layer("face_lower_%02d_%02d.png" % [face_style, skin_color]), FACE_LOWER_POS)
for slot in mini(_equipment_components.size(), COMPONENT_RECTS.size()):
var rel_path := String(_equipment_components[slot])
if rel_path.is_empty():
continue
_blit_reference_rect(base, _load_blp_layer(rel_path), COMPONENT_RECTS[slot])
base.generate_mipmaps()
return ImageTexture.create_from_image(base)
@@ -184,10 +205,15 @@ func _apply_texture(tex: ImageTexture) -> void:
func _load_layer(filename: String) -> Image:
var path := textures_dir.path_join(filename)
if not FileAccess.file_exists(path):
if not ResourceLoader.exists(path) and not FileAccess.file_exists(path):
return null
var img := Image.load_from_file(path)
return img
var texture := ResourceLoader.load(path, "Texture2D", ResourceLoader.CACHE_MODE_REUSE) as Texture2D
if texture != null:
var image := texture.get_image()
return image.duplicate() if image != null else null
if FileAccess.file_exists(path):
return Image.load_from_file(path)
return null
func _blit(dst: Image, src: Image, pos: Vector2i) -> void:
@@ -196,6 +222,58 @@ func _blit(dst: Image, src: Image, pos: Vector2i) -> void:
dst.blend_rect(src, Rect2i(Vector2i.ZERO, src.get_size()), pos)
func _blit_scaled(dst: Image, src: Image, reference_pos: Vector2i) -> void:
if src == null:
return
var scale := float(dst.get_width()) / 512.0
var target_pos := Vector2i(
int(round(float(reference_pos.x) * scale)),
int(round(float(reference_pos.y) * scale)))
if is_equal_approx(scale, 1.0):
_blit(dst, src, target_pos)
return
var target_size := Vector2i(
maxi(1, int(round(float(src.get_width()) * scale))),
maxi(1, int(round(float(src.get_height()) * scale))))
var scaled := src.duplicate()
scaled.resize(target_size.x, target_size.y, Image.INTERPOLATE_LANCZOS)
_blit(dst, scaled, target_pos)
func _blit_reference_rect(dst: Image, src: Image, reference_rect: Rect2i) -> void:
if src == null:
return
var scale_x := float(dst.get_width()) / 512.0
var scale_y := float(dst.get_height()) / 512.0
var target_rect := Rect2i(
Vector2i(
int(round(float(reference_rect.position.x) * scale_x)),
int(round(float(reference_rect.position.y) * scale_y))),
Vector2i(
maxi(1, int(round(float(reference_rect.size.x) * scale_x))),
maxi(1, int(round(float(reference_rect.size.y) * scale_y)))))
var scaled := src.duplicate()
scaled.resize(target_rect.size.x, target_rect.size.y, Image.INTERPOLATE_LANCZOS)
_blit(dst, scaled, target_rect.position)
func _load_blp_layer(rel_path: String) -> Image:
if rel_path.is_empty():
return null
if _blp_image_cache.has(rel_path):
return (_blp_image_cache[rel_path] as Image).duplicate() if _blp_image_cache[rel_path] != null else null
if not ClassDB.class_exists("BLPLoader"):
_blp_image_cache[rel_path] = null
return null
var abs_path := ProjectSettings.globalize_path(extracted_dir.trim_suffix("/").path_join(rel_path.replace("\\", "/")))
if not FileAccess.file_exists(abs_path):
_blp_image_cache[rel_path] = null
return null
var img: Image = ClassDB.instantiate("BLPLoader").call("load_image", abs_path)
_blp_image_cache[rel_path] = img
return img.duplicate() if img != null else null
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
_collect_meshes(root, result)
@@ -217,6 +295,11 @@ func refresh() -> void:
_recomposite()
func set_equipment_components(components: PackedStringArray) -> void:
_equipment_components = components
_recomposite()
## Returns the number of skin surfaces found (useful for debugging).
func get_skin_surface_count() -> int:
return _skin_surfaces.size()
@@ -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
const GEOSET_CONTROLLER_SCRIPT := preload("res://src/scenes/character/character_geoset_controller.gd")
const TEXTURE_COMPOSITOR_SCRIPT := preload("res://src/scenes/character/character_texture_compositor.gd")
const OUTFIT_RESOLVER_SCRIPT := preload("res://src/scenes/character/wow_character_outfit_resolver.gd")
const TILE_SIZE := 533.33333
const CHUNK_SIZE := TILE_SIZE / 16.0
const UNIT_SIZE := CHUNK_SIZE / 8.0
@@ -14,26 +18,39 @@ const UNIT_SIZE := CHUNK_SIZE / 8.0
@export var backward_speed: float = 4.5
@export var strafe_speed: float = 4.5
@export var sprint_multiplier: float = 6.0
@export var flight_vertical_speed: float = 7.0
@export var acceleration: float = 28.0
@export var mouse_sensitivity: float = 0.003
@export var camera_pitch_min: float = deg_to_rad(-65.0)
@export var camera_pitch_max: float = deg_to_rad(35.0)
@export var camera_height: float = 1.7
@export var camera_distance: float = 8.0
@export var camera_min_distance: float = 2.0
@export var camera_max_distance: float = 18.0
@export var camera_zoom_step: float = 1.0
@export var ground_offset: float = 0.05
@export var ground_snap_speed: float = 24.0
@export var camera_pivot_path: NodePath = NodePath("CameraPivot")
@export var camera_path: NodePath = NodePath("CameraPivot/Camera3D")
@export var visual_path: NodePath = NodePath("Visual")
@export var character_model_path: String = "res://src/resources/characters/HUMAN/MALE/HumanMale.glb"
@export var character_class_id: int = 1
@export var character_scale: float = 1.0
@export var character_yaw_offset_degrees: float = 90.0
@export var animation_blend_time: float = 0.15
var _camera_pivot: Node3D
var _camera: Camera3D
var _visual: Node3D
var _character_root: Node3D
var _animation_player: AnimationPlayer
var _active_animation := ""
var _captured := false
var _yaw := 0.0
var _pitch := deg_to_rad(-18.0)
var _horizontal_velocity := Vector3.ZERO
var _flight_enabled := false
var _adt_cache: Dictionary = {}
@@ -47,6 +64,7 @@ func _ready() -> void:
if spawn_at_tile_center:
global_position.x = (float(spawn_tile_x) + 0.5) * TILE_SIZE
global_position.z = (float(spawn_tile_y) + 0.5) * TILE_SIZE
_load_character_visual()
var ground := _sample_ground_height(global_position)
if is_finite(ground):
global_position.y = ground + ground_offset
@@ -58,9 +76,18 @@ func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
_captured = event.pressed
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if _captured else Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
camera_distance = clampf(camera_distance - camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
camera_distance = clampf(camera_distance + camera_zoom_step, camera_min_distance, camera_max_distance)
_apply_camera_transform()
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
_captured = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_SPACE:
_flight_enabled = not _flight_enabled
_horizontal_velocity = Vector3.ZERO
if _captured and event is InputEventMouseMotion:
_yaw -= event.relative.x * mouse_sensitivity
@@ -74,18 +101,24 @@ func _physics_process(delta: float) -> void:
var target_velocity := Vector3.ZERO
if input_dir.length_squared() > 0.0:
target_velocity = _movement_vector(input_dir)
if _flight_enabled:
target_velocity.y += _flight_vertical_velocity()
_horizontal_velocity = _horizontal_velocity.move_toward(target_velocity, acceleration * delta)
global_position += _horizontal_velocity * delta
var ground := _sample_ground_height(global_position)
if is_finite(ground):
var target_y := ground + ground_offset
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
if not _flight_enabled:
var ground := _sample_ground_height(global_position)
if is_finite(ground):
var target_y := ground + ground_offset
global_position.y = lerpf(global_position.y, target_y, clampf(ground_snap_speed * delta, 0.0, 1.0))
if _visual and _horizontal_velocity.length_squared() > 0.01:
var horizontal_motion := Vector2(_horizontal_velocity.x, _horizontal_velocity.z)
if _visual and horizontal_motion.length_squared() > 0.01:
_visual.global_rotation.y = atan2(-_horizontal_velocity.x, -_horizontal_velocity.z)
_update_character_animation(horizontal_motion.length_squared() > 0.04)
_apply_camera_transform()
@@ -105,8 +138,12 @@ func _get_input_dir() -> Vector2:
func _movement_vector(input_dir: Vector2) -> Vector3:
var forward := -global_basis.z
var right := global_basis.x
forward.y = 0.0
right.y = 0.0
if _flight_enabled and _camera_pivot:
forward = -_camera_pivot.global_basis.z
right = _camera_pivot.global_basis.x
else:
forward.y = 0.0
right.y = 0.0
forward = forward.normalized()
right = right.normalized()
@@ -116,6 +153,167 @@ func _movement_vector(input_dir: Vector2) -> Vector3:
return (forward * -input_dir.y * speed_z + right * input_dir.x * speed_x) * sprint
func _flight_vertical_velocity() -> float:
var dir := 0.0
if Input.is_key_pressed(KEY_E):
dir += 1.0
if Input.is_key_pressed(KEY_Q):
dir -= 1.0
var sprint := sprint_multiplier if Input.is_key_pressed(KEY_SHIFT) else 1.0
return dir * flight_vertical_speed * sprint
func _load_character_visual() -> void:
if character_model_path.is_empty() or _visual == null:
return
if _visual is MeshInstance3D:
(_visual as MeshInstance3D).mesh = null
_visual.position = Vector3.ZERO
if _character_root and is_instance_valid(_character_root):
_character_root.queue_free()
_character_root = null
_animation_player = null
_active_animation = ""
var scene: PackedScene = load(character_model_path)
if scene == null:
push_warning("ThirdPersonWowController: cannot load character model: %s" % character_model_path)
return
_character_root = Node3D.new()
_character_root.name = "CharacterModel"
_character_root.set_script(GEOSET_CONTROLLER_SCRIPT)
_character_root.scale = Vector3.ONE * maxf(character_scale, 0.001)
_character_root.rotation_degrees.y = character_yaw_offset_degrees
var instance := scene.instantiate()
instance.name = character_model_path.get_file().get_basename()
_character_root.add_child(instance)
var compositor := Node.new()
compositor.name = "CharacterTextureCompositor"
compositor.set_script(TEXTURE_COMPOSITOR_SCRIPT)
compositor.set("textures_dir", _character_textures_dir(character_model_path))
compositor.set("extracted_dir", extracted_dir)
_character_root.add_child(compositor)
_visual.add_child(_character_root)
await get_tree().process_frame
_fit_character_to_ground()
_apply_character_starter_outfit(compositor)
_animation_player = _find_animation_player(_character_root)
if _animation_player:
_prepare_animation_player(_animation_player)
_update_character_animation(false)
func _fit_character_to_ground() -> void:
if _character_root == null:
return
var aabb := _calculate_aabb(_character_root)
if aabb.size == Vector3.ZERO:
return
var local_min_y := aabb.position.y - _character_root.global_position.y
_character_root.position.y -= local_min_y
func _apply_character_starter_outfit(compositor: Node) -> void:
if _character_root == null or compositor == null:
return
var resolver: RefCounted = OUTFIT_RESOLVER_SCRIPT.new()
if not resolver.call("load_dbcs", extracted_dir):
return
var inferred: Dictionary = resolver.call("infer_race_gender_from_model_path", character_model_path)
var race := String(inferred.get("race", "Human"))
var gender := String(inferred.get("gender", "Male"))
var outfit: Dictionary = resolver.call("resolve_start_outfit", race, gender, character_class_id)
if outfit.is_empty():
return
if compositor.has_method("set_equipment_components"):
compositor.call("set_equipment_components", outfit.get("textures", PackedStringArray()))
if _character_root.has_method("apply_outfit_defaults"):
_character_root.call("apply_outfit_defaults", outfit)
func _update_character_animation(is_moving: bool) -> void:
if _animation_player == null:
return
var wanted := _choose_animation(["Run", "Walk"]) if is_moving else _choose_animation(["Stand", "Idle"])
if wanted.is_empty() or wanted == _active_animation:
return
_active_animation = wanted
_animation_player.play(wanted, animation_blend_time)
func _prepare_animation_player(player: AnimationPlayer) -> void:
player.playback_default_blend_time = animation_blend_time
for animation_name in player.get_animation_list():
var lower := String(animation_name).to_lower()
if lower == "stand" or lower == "idle" or lower == "run" or lower == "walk" or lower.contains("stand") or lower.contains("run") or lower.contains("walk"):
var animation := player.get_animation(animation_name)
if animation:
animation.loop_mode = Animation.LOOP_LINEAR
func _choose_animation(candidates: Array[String]) -> String:
if _animation_player == null:
return ""
for candidate in candidates:
if _animation_player.has_animation(candidate):
return candidate
var list := _animation_player.get_animation_list()
for candidate in candidates:
var lower := candidate.to_lower()
for animation_name in list:
if String(animation_name).to_lower().contains(lower):
return String(animation_name)
return ""
func _find_animation_player(root: Node) -> AnimationPlayer:
if root is AnimationPlayer:
return root
for child in root.get_children():
var found := _find_animation_player(child)
if found:
return found
return null
func _calculate_aabb(root: Node) -> AABB:
var result := AABB()
var has_aabb := false
for mesh_inst in _iter_mesh_instances(root):
if mesh_inst.mesh == null:
continue
var local_aabb := mesh_inst.mesh.get_aabb()
var global_aabb := mesh_inst.global_transform * local_aabb
if not has_aabb:
result = global_aabb
has_aabb = true
else:
result = result.merge(global_aabb)
return result if has_aabb else AABB()
func _iter_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
_collect_mesh_instances(root, result)
return result
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node)
for child in node.get_children():
_collect_mesh_instances(child, result)
func _character_textures_dir(model_path: String) -> String:
return model_path.get_base_dir().path_join(model_path.get_file().get_basename() + "_textures")
func _apply_camera_transform() -> void:
if _camera_pivot:
_camera_pivot.position = Vector3(0.0, camera_height, 0.0)
+51
View File
@@ -95,11 +95,13 @@ var _active_skybox_node: Node3D
var _last_logged_area_id := -1
var _last_logged_zone_id := -1
var _last_logged_profile_signature := ""
var _last_global_light_signature := ""
func _ready() -> void:
_resolve_nodes()
_prepare_environment()
_ensure_wow_shader_globals()
_loaded = _load_lighting_dbcs()
if _loaded:
print("WowSkyController: loaded %d LightParams profiles, %d area records and %d %s light volumes" % [
@@ -271,6 +273,43 @@ func _apply_sky(delta: float) -> void:
_sun.light_energy = lerpf(_sun.light_energy, clamp(_color_luma(diffuse) * lerpf(0.22, 1.45, sun_elevation), 0.05, 1.6), blend)
_apply_sun_direction(time_hours)
_update_wow_shader_globals(sun_elevation)
func _ensure_wow_shader_globals() -> void:
pass
func _update_wow_shader_globals(sun_elevation: float) -> void:
if not _environment:
return
var light_dir := Vector3(-0.35, 0.82, -0.45).normalized()
if _sun:
light_dir = -_sun.global_transform.basis.z.normalized()
var fog_range := Vector2(_environment.fog_depth_begin, _environment.fog_depth_end)
var raw_light: Color = _sun.light_color if _sun else Color(1.0, 0.91, 0.78, 1.0)
var shader_ambient := _world_shader_color(_environment.ambient_light_color, 0.65, Color(0.72, 0.70, 0.64, 1.0), 0.22)
var shader_light := _world_shader_color(raw_light, 0.45, Color(1.0, 0.92, 0.78, 1.0), 0.16)
var shader_fog := _world_shader_color(_environment.fog_light_color, 0.75, Color(0.62, 0.66, 0.66, 1.0), 0.38)
var density: float = clampf(_environment.fog_density * 2200.0, 0.0, 0.42)
var signature := "%s|%s|%s|%s|%.4f|%.3f" % [
str(shader_ambient),
str(shader_light),
str(light_dir.snapped(Vector3(0.001, 0.001, 0.001))),
str(fog_range.snapped(Vector2(1.0, 1.0))),
density,
sun_elevation]
if signature == _last_global_light_signature:
return
_last_global_light_signature = signature
RenderingServer.global_shader_parameter_set(&"wow_ambient_color", shader_ambient)
RenderingServer.global_shader_parameter_set(&"wow_light_color", shader_light)
RenderingServer.global_shader_parameter_set(&"wow_light_dir", light_dir)
RenderingServer.global_shader_parameter_set(&"wow_fog_color", shader_fog)
RenderingServer.global_shader_parameter_set(&"wow_fog_range", fog_range)
RenderingServer.global_shader_parameter_set(&"wow_fog_density", density)
RenderingServer.global_shader_parameter_set(&"wow_sun_elevation", sun_elevation)
func _load_lighting_dbcs() -> bool:
var base := _res_path(extracted_dir).path_join("DBFilesClient")
@@ -860,6 +899,18 @@ func _color_luma(color: Color) -> float:
return color.r * 0.2126 + color.g * 0.7152 + color.b * 0.0722
func _world_shader_color(color: Color, desaturate: float, neutral: Color, neutral_mix: float) -> Color:
var luma := clampf(_color_luma(color), 0.0, 1.25)
var gray := Color(luma, luma, luma, color.a)
var result := color.lerp(gray, clampf(desaturate, 0.0, 1.0))
result = result.lerp(neutral, clampf(neutral_mix, 0.0, 1.0))
return Color(
clampf(result.r, 0.0, 1.25),
clampf(result.g, 0.0, 1.25),
clampf(result.b, 0.0, 1.25),
color.a)
func _get_light_volume_count(id: int) -> int:
return int((_light_volumes_by_map.get(id, []) as Array).size())
@@ -61,6 +61,8 @@ max_concurrent_tile_tasks = 1
tile_lod_remove_ops_per_tick = 1
m2_build_groups_per_tick = 1
m2_multimesh_batch_size = 64
m2_animated_denylist_patterns = PackedStringArray("gryphonroost")
m2_animated_allowlist_patterns = PackedStringArray("creature/fish/", "creature/eagle/", "world/critter/")
wmo_render_group_ops_per_tick = 16
cached_tile_mesh_limit = 48
terrain_quality_mesh_cache_limit = 48
@@ -85,13 +87,14 @@ m2_tile_radius = 3
wmo_tile_radius = 5
m2_visibility_range = 1600.0
wmo_visibility_range = 3600.0
debug_streaming = true
hitch_profiler_enabled = true
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 22666, 80, 15200)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
script = ExtResource("2_player")
spawn_tile_x = 31
spawn_tile_y = 31
spawn_tile_y = 49
[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer" unique_id=1297880621]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0)
+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:
## 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
## Add --only-pattern waterfall to rebuild only matching model paths.
## Add --glb-animations to also emit animated GLB scenes through src/tools/m2_to_gltf.py.
extends SceneTree
const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd")
@@ -13,6 +15,10 @@ func _initialize() -> void:
var extracted := _res(_arg(args, "--extracted", "res://data/extracted"))
var output_dir := _res(_arg(args, "--output", "res://data/cache/m2_glb"))
var force := args.has("--force")
var glb_animations := args.has("--glb-animations")
var only_patterns := _arg_values(args, "--only-pattern")
var python_exe := _arg(args, "--python", "python")
var converter := _res(_arg(args, "--converter", "res://src/tools/m2_to_gltf.py"))
if not ClassDB.class_exists("ADTLoader") or not ClassDB.class_exists("M2Loader"):
push_error("GDExtension not loaded. Rebuild first.")
@@ -47,7 +53,7 @@ func _initialize() -> void:
var norm := str(rel).replace("\\", "/").to_lower()
if norm.ends_with(".mdx") or norm.ends_with(".mdl"):
norm = norm.get_basename() + ".m2"
if not norm.is_empty():
if not norm.is_empty() and _matches_only_patterns(norm, only_patterns):
unique[norm] = true
scanned += 1
@@ -55,8 +61,10 @@ func _initialize() -> void:
var m2_loader = ClassDB.instantiate("M2Loader")
var baked := 0
var baked_glb := 0
var skipped := 0
var failed := 0
var failed_glb := 0
var total := unique.size()
var i := 0
@@ -64,16 +72,22 @@ func _initialize() -> void:
i += 1
var stem: String = rel_path.get_basename()
var out_path := output_dir.path_join(stem + ".tscn")
if not force and ResourceLoader.exists(out_path):
skipped += 1
continue
var out_glb_path := output_dir.path_join(stem + ".glb")
var abs_m2 := ProjectSettings.globalize_path(extracted.path_join(rel_path))
if not FileAccess.file_exists(abs_m2):
failed += 1
continue
if not force and ResourceLoader.exists(out_path):
if glb_animations:
if _bake_glb_animation_cache(python_exe, converter, abs_m2, output_dir, out_glb_path, false):
baked_glb += 1
else:
failed_glb += 1
skipped += 1
continue
var data: Dictionary = m2_loader.call("load_m2", abs_m2)
if data.is_empty() or data.get("vertices", PackedVector3Array()).is_empty():
failed += 1
@@ -102,10 +116,17 @@ func _initialize() -> void:
continue
baked += 1
if glb_animations:
if _bake_glb_animation_cache(python_exe, converter, abs_m2, output_dir, out_glb_path, force):
baked_glb += 1
else:
failed_glb += 1
if i % 50 == 0 or i == total:
print("[%d/%d] baked=%d skipped=%d failed=%d" % [i, total, baked, skipped, failed])
print("[%d/%d] baked=%d glb=%d skipped=%d failed=%d glb_failed=%d" % [
i, total, baked, baked_glb, skipped, failed, failed_glb])
print("Done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed])
print("Done. baked=%d glb=%d skipped=%d failed=%d glb_failed=%d" % [
baked, baked_glb, skipped, failed, failed_glb])
quit(0)
@@ -116,12 +137,65 @@ func _arg(args: PackedStringArray, name: String, default: String) -> String:
return default
func _arg_values(args: PackedStringArray, name: String) -> PackedStringArray:
var result := PackedStringArray()
for i in args.size():
if args[i] != name or i + 1 >= args.size():
continue
for part in String(args[i + 1]).split(",", false):
var value := part.strip_edges().to_lower()
if not value.is_empty():
result.append(value)
return result
func _matches_only_patterns(path: String, patterns: PackedStringArray) -> bool:
if patterns.is_empty():
return true
var lower := path.to_lower()
for pattern in patterns:
if lower.contains(pattern):
return true
return false
func _res(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"):
return path
return "res://" + path.trim_prefix("./").trim_prefix("/")
func _bake_glb_animation_cache(
python_exe: String,
converter: String,
abs_m2: String,
output_dir: String,
out_glb_path: String,
force: bool) -> bool:
var abs_out_glb := ProjectSettings.globalize_path(out_glb_path)
if not force and FileAccess.file_exists(abs_out_glb):
return true
var abs_converter := ProjectSettings.globalize_path(converter)
var abs_output := ProjectSettings.globalize_path(output_dir)
if not FileAccess.file_exists(abs_converter):
push_warning("M2 GLB converter not found: %s" % converter)
return false
var stdout := []
var exit_code := OS.execute(
python_exe,
[abs_converter, abs_m2, abs_output],
stdout,
true,
false)
if exit_code != 0:
push_warning("animated GLB bake failed for %s exit=%d\n%s" % [
abs_m2,
exit_code,
"\n".join(stdout)])
return false
return FileAccess.file_exists(abs_out_glb)
func _set_owner_recursive(node: Node, owner_root: Node) -> void:
for child in node.get_children():
child.owner = owner_root
+29 -28
View File
@@ -650,7 +650,11 @@ class SkinParser:
class GltfBuilder:
def __init__(self):
self.asset = {"version": "2.0", "generator": "WoW M2 to GLTF converter"}
self.asset = {
"version": "2.0",
"generator": "WoW M2 to GLTF converter",
"extras": {"openwc_m2_anim_schema": "pivot_prefix_v1"},
}
self.nodes = []
self.meshes = []
self.skins = []
@@ -1027,36 +1031,41 @@ class GltfBuilder:
def _build_skeleton(self, m2: M2Parser):
bone_node_indices = []
pivot_node_indices = []
for i, bone in enumerate(m2.bones):
node_idx = len(self.nodes)
bone_node_indices.append(node_idx)
pivot_idx = len(self.nodes)
anim_idx = pivot_idx + 1
pivot_node_indices.append(pivot_idx)
bone_node_indices.append(anim_idx)
# Pivot in GLTF space (world-space first; parent-relative below)
# Pivot node holds the bind-pose offset. The child bone node stays
# at identity and receives animation channels. This mirrors WoW's
# T(pivot) * anim * T(-pivot) behavior closely enough for GLTF skinning.
px, py, pz = wow_pos_to_gltf(*bone['pivot'])
node = {
"name": f"bone_{i}",
self.nodes.append({
"name": f"bone_{i}_pivot",
"translation": [px, py, pz],
"rotation": [0.0, 0.0, 0.0, 1.0],
"scale": [1.0, 1.0, 1.0],
}
self.nodes.append(node)
"children": [anim_idx],
})
self.nodes.append({
"name": f"bone_{i}",
})
# Wire up children
# Wire pivot nodes into the hierarchy.
for i, bone in enumerate(m2.bones):
parent = bone['parent']
if parent >= 0 and parent < len(m2.bones):
parent_node = bone_node_indices[parent]
child_node = bone_node_indices[i]
child_node = pivot_node_indices[i]
self.nodes[parent_node].setdefault("children", []).append(child_node)
# Convert world-space pivots to parent-relative translations
# Convert world-space pivots to parent-relative pivot translations.
for i, bone in enumerate(m2.bones):
parent = bone['parent']
if parent >= 0 and parent < len(m2.bones):
ppx, ppy, ppz = wow_pos_to_gltf(*m2.bones[parent]['pivot'])
node = self.nodes[bone_node_indices[i]]
node = self.nodes[pivot_node_indices[i]]
t = node["translation"]
node["translation"] = [t[0]-ppx, t[1]-ppy, t[2]-ppz]
@@ -1064,7 +1073,7 @@ class GltfBuilder:
# WoW models have multiple root bones (main skeleton + attachment/socket
# bones each with parent=-1). Wrap them all under a single virtual
# "Armature" node at the origin so the skin has a valid common root.
root_bone_nodes = [bone_node_indices[i]
root_bone_nodes = [pivot_node_indices[i]
for i, b in enumerate(m2.bones) if b['parent'] < 0]
armature_idx = len(self.nodes)
self.nodes.append({
@@ -1310,20 +1319,12 @@ class GltfBuilder:
node_idx = bone_node_indices[bone_idx]
# Translation
# M2 t_track values are OFFSETS from the bone's rest position (pivot
# relative to parent pivot). GLTF animation overwrites node.translation
# entirely, so we must emit: rest_local + m2_offset (both in GLTF space).
# Pivot offset lives on the parent pivot node. The animated bone node
# receives only the M2 translation offset.
ts, vals = m2._resolve_track(bone['t_track'], seq_idx, 'vec3')
if ts and vals:
rest = self.nodes[node_idx].get("translation", [0.0, 0.0, 0.0])
rx, ry, rz = rest[0], rest[1], rest[2]
# Use a factory to capture rx/ry/rz by value (avoid late-binding bug)
def _make_trans_fn(rx, ry, rz):
def fn(v):
cx, cy, cz = wow_pos_to_gltf(*v)
return (rx + cx, ry + cy, rz + cz)
return fn
s_idx = self._anim_sampler(ts, vals, 'VEC3', _make_trans_fn(rx, ry, rz))
s_idx = self._anim_sampler(ts, vals, 'VEC3',
lambda v: wow_pos_to_gltf(*v))
if s_idx is not None:
samplers.append(s_idx)
channels.append({"sampler": len(samplers)-1,