2026-02-02 12:24:50 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* CharacterRenderer — GPU rendering of M2 character models with skeletal animation
|
|
|
|
|
|
*
|
|
|
|
|
|
* Handles:
|
|
|
|
|
|
* - Uploading M2 vertex/index data to OpenGL VAO/VBO/EBO
|
|
|
|
|
|
* - Per-frame bone matrix computation (hierarchical, with keyframe interpolation)
|
|
|
|
|
|
* - GPU vertex skinning via a bone-matrix uniform array in the vertex shader
|
|
|
|
|
|
* - Per-batch texture binding through the M2 texture-lookup indirection
|
|
|
|
|
|
* - Geoset filtering (activeGeosets) to show/hide body part groups
|
|
|
|
|
|
* - CPU texture compositing for character skins (base skin + underwear overlays)
|
|
|
|
|
|
*
|
|
|
|
|
|
* The character texture compositing uses the WoW CharComponentTextureSections
|
|
|
|
|
|
* layout, placing region overlays (pelvis, torso, etc.) at their correct pixel
|
|
|
|
|
|
* positions on the 512×512 body skin atlas. Region coordinates sourced from
|
|
|
|
|
|
* the original WoW Model Viewer (charcontrol.h, REGION_FAC=2).
|
|
|
|
|
|
*/
|
|
|
|
|
|
#include "rendering/character_renderer.hpp"
|
|
|
|
|
|
#include "rendering/shader.hpp"
|
|
|
|
|
|
#include "rendering/texture.hpp"
|
|
|
|
|
|
#include "rendering/camera.hpp"
|
|
|
|
|
|
#include "pipeline/asset_manager.hpp"
|
|
|
|
|
|
#include "pipeline/blp_loader.hpp"
|
|
|
|
|
|
#include "core/logger.hpp"
|
|
|
|
|
|
#include <GL/glew.h>
|
|
|
|
|
|
#include <glm/gtc/matrix_transform.hpp>
|
|
|
|
|
|
#include <glm/gtc/type_ptr.hpp>
|
|
|
|
|
|
#include <glm/gtx/quaternion.hpp>
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
|
#include <cmath>
|
2026-02-03 22:24:17 -08:00
|
|
|
|
#include <filesystem>
|
2026-02-10 19:30:45 -08:00
|
|
|
|
#include <future>
|
|
|
|
|
|
#include <functional>
|
|
|
|
|
|
#include <unordered_map>
|
|
|
|
|
|
#include <unordered_set>
|
2026-02-12 16:29:36 -08:00
|
|
|
|
#include <cstdlib>
|
2026-02-17 03:18:01 -08:00
|
|
|
|
#include <fstream>
|
2026-02-12 16:29:36 -08:00
|
|
|
|
#include <limits>
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
namespace wowee {
|
|
|
|
|
|
namespace rendering {
|
|
|
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
|
namespace {
|
|
|
|
|
|
size_t envSizeMBOrDefault(const char* name, size_t defMb) {
|
|
|
|
|
|
const char* v = std::getenv(name);
|
|
|
|
|
|
if (!v || !*v) return defMb;
|
|
|
|
|
|
char* end = nullptr;
|
|
|
|
|
|
unsigned long long mb = std::strtoull(v, &end, 10);
|
|
|
|
|
|
if (end == v || mb == 0) return defMb;
|
|
|
|
|
|
if (mb > (std::numeric_limits<size_t>::max() / (1024ull * 1024ull))) return defMb;
|
|
|
|
|
|
return static_cast<size_t>(mb);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
size_t approxTextureBytesWithMips(int w, int h) {
|
|
|
|
|
|
if (w <= 0 || h <= 0) return 0;
|
|
|
|
|
|
size_t base = static_cast<size_t>(w) * static_cast<size_t>(h) * 4ull;
|
|
|
|
|
|
return base + (base / 3); // ~4/3 for mip chain
|
|
|
|
|
|
}
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
CharacterRenderer::CharacterRenderer() {
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CharacterRenderer::~CharacterRenderer() {
|
|
|
|
|
|
shutdown();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool CharacterRenderer::initialize() {
|
|
|
|
|
|
core::Logger::getInstance().info("Initializing character renderer...");
|
|
|
|
|
|
|
|
|
|
|
|
// Create character shader with skeletal animation
|
|
|
|
|
|
const char* vertexSrc = R"(
|
|
|
|
|
|
#version 330 core
|
|
|
|
|
|
layout (location = 0) in vec3 aPos;
|
|
|
|
|
|
layout (location = 1) in vec4 aBoneWeights;
|
|
|
|
|
|
layout (location = 2) in ivec4 aBoneIndices;
|
|
|
|
|
|
layout (location = 3) in vec3 aNormal;
|
|
|
|
|
|
layout (location = 4) in vec2 aTexCoord;
|
|
|
|
|
|
|
|
|
|
|
|
uniform mat4 uModel;
|
|
|
|
|
|
uniform mat4 uView;
|
|
|
|
|
|
uniform mat4 uProjection;
|
2026-02-15 20:53:01 -08:00
|
|
|
|
uniform mat4 uBones[240];
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
out vec3 FragPos;
|
|
|
|
|
|
out vec3 Normal;
|
|
|
|
|
|
out vec2 TexCoord;
|
|
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
// Skinning: blend bone transformations
|
|
|
|
|
|
mat4 boneTransform = mat4(0.0);
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.x] * aBoneWeights.x;
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.y] * aBoneWeights.y;
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.z] * aBoneWeights.z;
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.w] * aBoneWeights.w;
|
|
|
|
|
|
|
|
|
|
|
|
// Transform position and normal
|
|
|
|
|
|
vec4 skinnedPos = boneTransform * vec4(aPos, 1.0);
|
|
|
|
|
|
vec4 worldPos = uModel * skinnedPos;
|
|
|
|
|
|
|
|
|
|
|
|
FragPos = worldPos.xyz;
|
2026-02-02 23:03:45 -08:00
|
|
|
|
// Use mat3 directly - avoid expensive inverse() in shader
|
|
|
|
|
|
// Works correctly for uniform scaling; normalize in fragment shader handles the rest
|
|
|
|
|
|
Normal = mat3(uModel) * mat3(boneTransform) * aNormal;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
TexCoord = aTexCoord;
|
|
|
|
|
|
|
|
|
|
|
|
gl_Position = uProjection * uView * worldPos;
|
|
|
|
|
|
}
|
|
|
|
|
|
)";
|
|
|
|
|
|
|
|
|
|
|
|
const char* fragmentSrc = R"(
|
|
|
|
|
|
#version 330 core
|
|
|
|
|
|
in vec3 FragPos;
|
|
|
|
|
|
in vec3 Normal;
|
|
|
|
|
|
in vec2 TexCoord;
|
|
|
|
|
|
|
|
|
|
|
|
uniform sampler2D uTexture0;
|
|
|
|
|
|
uniform vec3 uLightDir;
|
2026-02-04 15:28:47 -08:00
|
|
|
|
uniform vec3 uLightColor;
|
|
|
|
|
|
uniform float uSpecularIntensity;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
uniform vec3 uViewPos;
|
|
|
|
|
|
|
2026-02-04 15:05:46 -08:00
|
|
|
|
uniform vec3 uFogColor;
|
|
|
|
|
|
uniform float uFogStart;
|
|
|
|
|
|
uniform float uFogEnd;
|
|
|
|
|
|
|
|
|
|
|
|
uniform sampler2DShadow uShadowMap;
|
|
|
|
|
|
uniform mat4 uLightSpaceMatrix;
|
|
|
|
|
|
uniform int uShadowEnabled;
|
2026-02-04 16:22:18 -08:00
|
|
|
|
uniform float uShadowStrength;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
uniform float uOpacity;
|
2026-02-04 15:05:46 -08:00
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
out vec4 FragColor;
|
|
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
vec3 normal = normalize(Normal);
|
|
|
|
|
|
vec3 lightDir = normalize(uLightDir);
|
|
|
|
|
|
|
2026-02-04 15:05:46 -08:00
|
|
|
|
// Diffuse lighting
|
2026-02-02 12:24:50 -08:00
|
|
|
|
float diff = max(dot(normal, lightDir), 0.0);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
|
|
|
|
|
|
// Blinn-Phong specular
|
|
|
|
|
|
vec3 viewDir = normalize(uViewPos - FragPos);
|
|
|
|
|
|
vec3 halfDir = normalize(lightDir + viewDir);
|
|
|
|
|
|
float spec = pow(max(dot(normal, halfDir), 0.0), 32.0);
|
2026-02-04 15:28:47 -08:00
|
|
|
|
vec3 specular = spec * uLightColor * uSpecularIntensity;
|
2026-02-04 15:05:46 -08:00
|
|
|
|
|
|
|
|
|
|
// Shadow mapping
|
|
|
|
|
|
float shadow = 1.0;
|
|
|
|
|
|
if (uShadowEnabled != 0) {
|
|
|
|
|
|
vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0);
|
|
|
|
|
|
vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5;
|
|
|
|
|
|
if (proj.z <= 1.0 && proj.x >= 0.0 && proj.x <= 1.0 && proj.y >= 0.0 && proj.y <= 1.0) {
|
2026-02-04 16:30:24 -08:00
|
|
|
|
float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5));
|
|
|
|
|
|
float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
float bias = max(0.005 * (1.0 - abs(dot(normal, lightDir))), 0.001);
|
2026-02-18 21:09:00 -08:00
|
|
|
|
// Single hardware PCF tap — GL_LINEAR + compare mode gives 2×2 bilinear PCF for free
|
|
|
|
|
|
shadow = texture(uShadowMap, vec3(proj.xy, proj.z - bias));
|
2026-02-04 16:30:24 -08:00
|
|
|
|
shadow = mix(1.0, shadow, coverageFade);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-04 16:22:18 -08:00
|
|
|
|
shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0));
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
// Ambient
|
|
|
|
|
|
vec3 ambient = vec3(0.3);
|
|
|
|
|
|
|
|
|
|
|
|
// Sample texture
|
|
|
|
|
|
vec4 texColor = texture(uTexture0, TexCoord);
|
|
|
|
|
|
|
|
|
|
|
|
// Combine
|
2026-02-04 15:05:46 -08:00
|
|
|
|
vec3 result = (ambient + (diff * vec3(1.0) + specular) * shadow) * texColor.rgb;
|
|
|
|
|
|
|
|
|
|
|
|
// Fog
|
|
|
|
|
|
float fogDist = length(uViewPos - FragPos);
|
|
|
|
|
|
float fogFactor = clamp((uFogEnd - fogDist) / (uFogEnd - uFogStart), 0.0, 1.0);
|
|
|
|
|
|
result = mix(uFogColor, result, fogFactor);
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
// Apply opacity (for fade-in effects)
|
|
|
|
|
|
FragColor = vec4(result, uOpacity);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
)";
|
|
|
|
|
|
|
|
|
|
|
|
// Log GPU uniform limit
|
|
|
|
|
|
GLint maxComponents = 0;
|
|
|
|
|
|
glGetIntegerv(GL_MAX_VERTEX_UNIFORM_COMPONENTS, &maxComponents);
|
|
|
|
|
|
core::Logger::getInstance().info("GPU max vertex uniform components: ", maxComponents,
|
|
|
|
|
|
" (supports ~", maxComponents / 16, " mat4)");
|
|
|
|
|
|
|
|
|
|
|
|
characterShader = std::make_unique<Shader>();
|
|
|
|
|
|
if (!characterShader->loadFromSource(vertexSrc, fragmentSrc)) {
|
|
|
|
|
|
core::Logger::getInstance().error("Failed to create character shader");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 16:41:40 -08:00
|
|
|
|
const char* shadowVertSrc = R"(
|
|
|
|
|
|
#version 330 core
|
|
|
|
|
|
layout (location = 0) in vec3 aPos;
|
|
|
|
|
|
layout (location = 1) in vec4 aBoneWeights;
|
|
|
|
|
|
layout (location = 2) in ivec4 aBoneIndices;
|
|
|
|
|
|
layout (location = 4) in vec2 aTexCoord;
|
|
|
|
|
|
|
|
|
|
|
|
uniform mat4 uLightSpaceMatrix;
|
|
|
|
|
|
uniform mat4 uModel;
|
2026-02-15 20:53:01 -08:00
|
|
|
|
uniform mat4 uBones[240];
|
2026-02-04 16:41:40 -08:00
|
|
|
|
|
|
|
|
|
|
out vec2 vTexCoord;
|
|
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
mat4 boneTransform = mat4(0.0);
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.x] * aBoneWeights.x;
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.y] * aBoneWeights.y;
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.z] * aBoneWeights.z;
|
|
|
|
|
|
boneTransform += uBones[aBoneIndices.w] * aBoneWeights.w;
|
|
|
|
|
|
vec4 skinnedPos = boneTransform * vec4(aPos, 1.0);
|
|
|
|
|
|
vTexCoord = aTexCoord;
|
|
|
|
|
|
gl_Position = uLightSpaceMatrix * uModel * skinnedPos;
|
|
|
|
|
|
}
|
|
|
|
|
|
)";
|
|
|
|
|
|
|
|
|
|
|
|
const char* shadowFragSrc = R"(
|
|
|
|
|
|
#version 330 core
|
|
|
|
|
|
in vec2 vTexCoord;
|
|
|
|
|
|
uniform sampler2D uTexture;
|
|
|
|
|
|
uniform bool uAlphaTest;
|
|
|
|
|
|
void main() {
|
|
|
|
|
|
if (uAlphaTest && texture(uTexture, vTexCoord).a < 0.5) discard;
|
|
|
|
|
|
}
|
|
|
|
|
|
)";
|
|
|
|
|
|
|
|
|
|
|
|
auto compileStage = [](GLenum type, const char* src) -> GLuint {
|
|
|
|
|
|
GLuint shader = glCreateShader(type);
|
|
|
|
|
|
glShaderSource(shader, 1, &src, nullptr);
|
|
|
|
|
|
glCompileShader(shader);
|
|
|
|
|
|
GLint ok = 0;
|
|
|
|
|
|
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
|
|
|
|
|
|
if (!ok) {
|
|
|
|
|
|
char log[512];
|
|
|
|
|
|
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
|
|
|
|
|
|
LOG_ERROR("Character shadow shader compile error: ", log);
|
|
|
|
|
|
glDeleteShader(shader);
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return shader;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
GLuint shVs = compileStage(GL_VERTEX_SHADER, shadowVertSrc);
|
|
|
|
|
|
GLuint shFs = compileStage(GL_FRAGMENT_SHADER, shadowFragSrc);
|
|
|
|
|
|
if (!shVs || !shFs) {
|
|
|
|
|
|
if (shVs) glDeleteShader(shVs);
|
|
|
|
|
|
if (shFs) glDeleteShader(shFs);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
shadowCasterProgram = glCreateProgram();
|
|
|
|
|
|
glAttachShader(shadowCasterProgram, shVs);
|
|
|
|
|
|
glAttachShader(shadowCasterProgram, shFs);
|
|
|
|
|
|
glLinkProgram(shadowCasterProgram);
|
|
|
|
|
|
GLint linked = 0;
|
|
|
|
|
|
glGetProgramiv(shadowCasterProgram, GL_LINK_STATUS, &linked);
|
|
|
|
|
|
glDeleteShader(shVs);
|
|
|
|
|
|
glDeleteShader(shFs);
|
|
|
|
|
|
if (!linked) {
|
|
|
|
|
|
char log[512];
|
|
|
|
|
|
glGetProgramInfoLog(shadowCasterProgram, sizeof(log), nullptr, log);
|
|
|
|
|
|
LOG_ERROR("Character shadow shader link error: ", log);
|
|
|
|
|
|
glDeleteProgram(shadowCasterProgram);
|
|
|
|
|
|
shadowCasterProgram = 0;
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Create 1x1 white fallback texture
|
|
|
|
|
|
uint8_t white[] = { 255, 255, 255, 255 };
|
|
|
|
|
|
glGenTextures(1, &whiteTexture);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, whiteTexture);
|
|
|
|
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, white);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
|
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
|
// Diagnostics-only: cache lifetime is currently tied to renderer lifetime.
|
|
|
|
|
|
textureCacheBudgetBytes_ = envSizeMBOrDefault("WOWEE_CHARACTER_TEX_CACHE_MB", 2048) * 1024ull * 1024ull;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
core::Logger::getInstance().info("Character renderer initialized");
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::shutdown() {
|
|
|
|
|
|
// Clean up GPU resources
|
|
|
|
|
|
for (auto& pair : models) {
|
|
|
|
|
|
auto& gpuModel = pair.second;
|
|
|
|
|
|
if (gpuModel.vao) {
|
|
|
|
|
|
glDeleteVertexArrays(1, &gpuModel.vao);
|
|
|
|
|
|
glDeleteBuffers(1, &gpuModel.vbo);
|
|
|
|
|
|
glDeleteBuffers(1, &gpuModel.ebo);
|
|
|
|
|
|
}
|
|
|
|
|
|
for (GLuint texId : gpuModel.textureIds) {
|
|
|
|
|
|
if (texId && texId != whiteTexture) {
|
|
|
|
|
|
glDeleteTextures(1, &texId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Clean up texture cache
|
|
|
|
|
|
for (auto& pair : textureCache) {
|
2026-02-12 16:29:36 -08:00
|
|
|
|
GLuint texId = pair.second.id;
|
|
|
|
|
|
if (texId && texId != whiteTexture) {
|
|
|
|
|
|
glDeleteTextures(1, &texId);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
textureCache.clear();
|
2026-02-12 16:29:36 -08:00
|
|
|
|
textureCacheBytes_ = 0;
|
|
|
|
|
|
textureCacheCounter_ = 0;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
if (whiteTexture) {
|
|
|
|
|
|
glDeleteTextures(1, &whiteTexture);
|
|
|
|
|
|
whiteTexture = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
models.clear();
|
|
|
|
|
|
instances.clear();
|
|
|
|
|
|
characterShader.reset();
|
2026-02-04 16:41:40 -08:00
|
|
|
|
if (shadowCasterProgram) {
|
|
|
|
|
|
glDeleteProgram(shadowCasterProgram);
|
|
|
|
|
|
shadowCasterProgram = 0;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
GLuint CharacterRenderer::loadTexture(const std::string& path) {
|
|
|
|
|
|
// Skip empty or whitespace-only paths (type-0 textures have no filename)
|
|
|
|
|
|
if (path.empty()) return whiteTexture;
|
|
|
|
|
|
bool allWhitespace = true;
|
|
|
|
|
|
for (char c : path) {
|
|
|
|
|
|
if (c != ' ' && c != '\t' && c != '\0' && c != '\n') { allWhitespace = false; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (allWhitespace) return whiteTexture;
|
|
|
|
|
|
|
2026-02-12 16:15:25 -08:00
|
|
|
|
auto normalizeKey = [](std::string key) {
|
|
|
|
|
|
std::replace(key.begin(), key.end(), '/', '\\');
|
|
|
|
|
|
std::transform(key.begin(), key.end(), key.begin(),
|
|
|
|
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
|
|
|
|
return key;
|
|
|
|
|
|
};
|
|
|
|
|
|
std::string key = normalizeKey(path);
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Check cache
|
2026-02-12 16:15:25 -08:00
|
|
|
|
auto it = textureCache.find(key);
|
2026-02-12 16:29:36 -08:00
|
|
|
|
if (it != textureCache.end()) {
|
|
|
|
|
|
it->second.lastUse = ++textureCacheCounter_;
|
|
|
|
|
|
return it->second.id;
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
if (!assetManager || !assetManager->isInitialized()) {
|
|
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 00:45:47 -08:00
|
|
|
|
// Check negative cache to avoid repeated file I/O for textures that don't exist
|
|
|
|
|
|
if (failedTextureCache_.count(key)) {
|
|
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 16:15:25 -08:00
|
|
|
|
auto blpImage = assetManager->loadTexture(key);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (!blpImage.isValid()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("Failed to load texture: ", path);
|
2026-02-16 00:45:47 -08:00
|
|
|
|
failedTextureCache_.insert(key);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
GLuint texId;
|
|
|
|
|
|
glGenTextures(1, &texId);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, texId);
|
|
|
|
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, blpImage.width, blpImage.height,
|
|
|
|
|
|
0, GL_RGBA, GL_UNSIGNED_BYTE, blpImage.data.data());
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
|
|
|
|
|
|
glGenerateMipmap(GL_TEXTURE_2D);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
applyAnisotropicFiltering();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
|
|
|
|
|
2026-02-12 16:29:36 -08:00
|
|
|
|
TextureCacheEntry e;
|
|
|
|
|
|
e.id = texId;
|
|
|
|
|
|
e.approxBytes = approxTextureBytesWithMips(blpImage.width, blpImage.height);
|
|
|
|
|
|
e.lastUse = ++textureCacheCounter_;
|
|
|
|
|
|
textureCacheBytes_ += e.approxBytes;
|
|
|
|
|
|
textureCache[key] = e;
|
|
|
|
|
|
if (textureCacheBytes_ > textureCacheBudgetBytes_) {
|
|
|
|
|
|
core::Logger::getInstance().warning(
|
|
|
|
|
|
"Character texture cache over budget: ",
|
|
|
|
|
|
textureCacheBytes_ / (1024 * 1024), " MB > ",
|
|
|
|
|
|
textureCacheBudgetBytes_ / (1024 * 1024), " MB (textures=", textureCache.size(), ")");
|
|
|
|
|
|
}
|
2026-02-02 12:24:50 -08:00
|
|
|
|
core::Logger::getInstance().info("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")");
|
|
|
|
|
|
return texId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Alpha-blend overlay onto composite at (dstX, dstY)
|
|
|
|
|
|
static void blitOverlay(std::vector<uint8_t>& composite, int compW, int compH,
|
|
|
|
|
|
const pipeline::BLPImage& overlay, int dstX, int dstY) {
|
|
|
|
|
|
for (int sy = 0; sy < overlay.height; sy++) {
|
|
|
|
|
|
int dy = dstY + sy;
|
|
|
|
|
|
if (dy < 0 || dy >= compH) continue;
|
|
|
|
|
|
for (int sx = 0; sx < overlay.width; sx++) {
|
|
|
|
|
|
int dx = dstX + sx;
|
|
|
|
|
|
if (dx < 0 || dx >= compW) continue;
|
|
|
|
|
|
|
|
|
|
|
|
size_t srcIdx = (static_cast<size_t>(sy) * overlay.width + sx) * 4;
|
|
|
|
|
|
size_t dstIdx = (static_cast<size_t>(dy) * compW + dx) * 4;
|
|
|
|
|
|
|
|
|
|
|
|
uint8_t srcA = overlay.data[srcIdx + 3];
|
|
|
|
|
|
if (srcA == 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (srcA == 255) {
|
|
|
|
|
|
composite[dstIdx + 0] = overlay.data[srcIdx + 0];
|
|
|
|
|
|
composite[dstIdx + 1] = overlay.data[srcIdx + 1];
|
|
|
|
|
|
composite[dstIdx + 2] = overlay.data[srcIdx + 2];
|
|
|
|
|
|
composite[dstIdx + 3] = 255;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
float alpha = srcA / 255.0f;
|
|
|
|
|
|
float invAlpha = 1.0f - alpha;
|
|
|
|
|
|
composite[dstIdx + 0] = static_cast<uint8_t>(overlay.data[srcIdx + 0] * alpha + composite[dstIdx + 0] * invAlpha);
|
|
|
|
|
|
composite[dstIdx + 1] = static_cast<uint8_t>(overlay.data[srcIdx + 1] * alpha + composite[dstIdx + 1] * invAlpha);
|
|
|
|
|
|
composite[dstIdx + 2] = static_cast<uint8_t>(overlay.data[srcIdx + 2] * alpha + composite[dstIdx + 2] * invAlpha);
|
|
|
|
|
|
composite[dstIdx + 3] = std::max(composite[dstIdx + 3], srcA);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
|
// Nearest-neighbor NxN scale blit of overlay onto composite at (dstX, dstY)
|
|
|
|
|
|
static void blitOverlayScaledN(std::vector<uint8_t>& composite, int compW, int compH,
|
|
|
|
|
|
const pipeline::BLPImage& overlay, int dstX, int dstY, int scale) {
|
|
|
|
|
|
if (scale < 1) scale = 1;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
for (int sy = 0; sy < overlay.height; sy++) {
|
|
|
|
|
|
for (int sx = 0; sx < overlay.width; sx++) {
|
|
|
|
|
|
size_t srcIdx = (static_cast<size_t>(sy) * overlay.width + sx) * 4;
|
|
|
|
|
|
uint8_t srcA = overlay.data[srcIdx + 3];
|
|
|
|
|
|
if (srcA == 0) continue;
|
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
|
// Write to scale×scale block of destination pixels
|
|
|
|
|
|
for (int dy2 = 0; dy2 < scale; dy2++) {
|
|
|
|
|
|
int dy = dstY + sy * scale + dy2;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (dy < 0 || dy >= compH) continue;
|
2026-02-17 03:18:01 -08:00
|
|
|
|
for (int dx2 = 0; dx2 < scale; dx2++) {
|
|
|
|
|
|
int dx = dstX + sx * scale + dx2;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (dx < 0 || dx >= compW) continue;
|
|
|
|
|
|
|
|
|
|
|
|
size_t dstIdx = (static_cast<size_t>(dy) * compW + dx) * 4;
|
|
|
|
|
|
if (srcA == 255) {
|
|
|
|
|
|
composite[dstIdx + 0] = overlay.data[srcIdx + 0];
|
|
|
|
|
|
composite[dstIdx + 1] = overlay.data[srcIdx + 1];
|
|
|
|
|
|
composite[dstIdx + 2] = overlay.data[srcIdx + 2];
|
|
|
|
|
|
composite[dstIdx + 3] = 255;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
float alpha = srcA / 255.0f;
|
|
|
|
|
|
float invAlpha = 1.0f - alpha;
|
|
|
|
|
|
composite[dstIdx + 0] = static_cast<uint8_t>(overlay.data[srcIdx + 0] * alpha + composite[dstIdx + 0] * invAlpha);
|
|
|
|
|
|
composite[dstIdx + 1] = static_cast<uint8_t>(overlay.data[srcIdx + 1] * alpha + composite[dstIdx + 1] * invAlpha);
|
|
|
|
|
|
composite[dstIdx + 2] = static_cast<uint8_t>(overlay.data[srcIdx + 2] * alpha + composite[dstIdx + 2] * invAlpha);
|
|
|
|
|
|
composite[dstIdx + 3] = std::max(composite[dstIdx + 3], srcA);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
|
// Legacy 2x wrapper
|
|
|
|
|
|
static void blitOverlayScaled2x(std::vector<uint8_t>& composite, int compW, int compH,
|
|
|
|
|
|
const pipeline::BLPImage& overlay, int dstX, int dstY) {
|
|
|
|
|
|
blitOverlayScaledN(composite, compW, compH, overlay, dstX, dstY, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
GLuint CharacterRenderer::compositeTextures(const std::vector<std::string>& layerPaths) {
|
|
|
|
|
|
if (layerPaths.empty() || !assetManager || !assetManager->isInitialized()) {
|
|
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load base layer
|
|
|
|
|
|
auto base = assetManager->loadTexture(layerPaths[0]);
|
|
|
|
|
|
if (!base.isValid()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("Composite: failed to load base layer: ", layerPaths[0]);
|
|
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Copy base pixel data as our working buffer
|
|
|
|
|
|
std::vector<uint8_t> composite = base.data;
|
|
|
|
|
|
int width = base.width;
|
|
|
|
|
|
int height = base.height;
|
|
|
|
|
|
|
|
|
|
|
|
core::Logger::getInstance().info("Composite: base layer ", width, "x", height, " from ", layerPaths[0]);
|
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
|
// WoW character texture atlas regions (from WoW Model Viewer / CharComponentTextureSections)
|
|
|
|
|
|
// Coordinates at 256x256 base resolution:
|
|
|
|
|
|
// Region X Y W H
|
|
|
|
|
|
// Base 0 0 256 256
|
|
|
|
|
|
// Arm Upper 0 0 128 64
|
|
|
|
|
|
// Arm Lower 0 64 128 64
|
|
|
|
|
|
// Hand 0 128 128 32
|
|
|
|
|
|
// Face Upper 0 160 128 32
|
|
|
|
|
|
// Face Lower 0 192 128 64
|
|
|
|
|
|
// Torso Upper 128 0 128 64
|
|
|
|
|
|
// Torso Lower 128 64 128 32
|
|
|
|
|
|
// Pelvis Upper 128 96 128 64
|
|
|
|
|
|
// Pelvis Lower 128 160 128 64
|
|
|
|
|
|
// Foot 128 224 128 32
|
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
|
// Scale factor: base texture may be larger than the 256x256 reference atlas
|
|
|
|
|
|
int coordScale = width / 256;
|
|
|
|
|
|
if (coordScale < 1) coordScale = 1;
|
|
|
|
|
|
|
|
|
|
|
|
// Atlas region sizes at 256x256 base (w, h) for known regions
|
|
|
|
|
|
struct AtlasRegion { int x, y, w, h; };
|
|
|
|
|
|
static const AtlasRegion faceLowerRegion256 = {0, 192, 128, 64};
|
|
|
|
|
|
static const AtlasRegion faceUpperRegion256 = {0, 160, 128, 32};
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Alpha-blend each overlay onto the composite
|
|
|
|
|
|
for (size_t layer = 1; layer < layerPaths.size(); layer++) {
|
|
|
|
|
|
if (layerPaths[layer].empty()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
auto overlay = assetManager->loadTexture(layerPaths[layer]);
|
|
|
|
|
|
if (!overlay.isValid()) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
core::Logger::getInstance().warning("Composite: FAILED to load overlay: ", layerPaths[layer]);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
core::Logger::getInstance().info("Composite: overlay ", layerPaths[layer],
|
|
|
|
|
|
" (", overlay.width, "x", overlay.height, ")");
|
|
|
|
|
|
|
|
|
|
|
|
if (overlay.width == width && overlay.height == height) {
|
|
|
|
|
|
// Same size: full alpha-blend
|
|
|
|
|
|
blitOverlay(composite, width, height, overlay, 0, 0);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Determine region by filename keywords
|
2026-02-15 12:53:15 -08:00
|
|
|
|
// Coordinates scale with base texture size (256x256 is reference)
|
2026-02-02 12:24:50 -08:00
|
|
|
|
int dstX = 0, dstY = 0;
|
2026-02-17 03:18:01 -08:00
|
|
|
|
int expectedW256 = 0, expectedH256 = 0; // Expected size at 256-base
|
2026-02-02 12:24:50 -08:00
|
|
|
|
std::string pathLower = layerPaths[layer];
|
|
|
|
|
|
for (auto& c : pathLower) c = std::tolower(c);
|
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
|
if (pathLower.find("faceupper") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = faceUpperRegion256.x; dstY = faceUpperRegion256.y;
|
|
|
|
|
|
expectedW256 = faceUpperRegion256.w; expectedH256 = faceUpperRegion256.h;
|
2026-02-15 12:53:15 -08:00
|
|
|
|
} else if (pathLower.find("facelower") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = faceLowerRegion256.x; dstY = faceLowerRegion256.y;
|
|
|
|
|
|
expectedW256 = faceLowerRegion256.w; expectedH256 = faceLowerRegion256.h;
|
2026-02-15 12:53:15 -08:00
|
|
|
|
} else if (pathLower.find("pelvis") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = 128; dstY = 96;
|
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("torso") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = 128; dstY = 0;
|
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("armupper") != std::string::npos) {
|
|
|
|
|
|
dstX = 0; dstY = 0;
|
2026-02-17 03:18:01 -08:00
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("armlower") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = 0; dstY = 64;
|
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("hand") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = 0; dstY = 128;
|
|
|
|
|
|
expectedW256 = 128; expectedH256 = 32;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = 128; dstY = 224;
|
|
|
|
|
|
expectedW256 = 128; expectedH256 = 32;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) {
|
2026-02-17 03:18:01 -08:00
|
|
|
|
dstX = 128; dstY = 160;
|
|
|
|
|
|
expectedW256 = 128; expectedH256 = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
// Unknown — center placement as fallback
|
|
|
|
|
|
dstX = (width - overlay.width) / 2;
|
|
|
|
|
|
dstY = (height - overlay.height) / 2;
|
2026-02-17 03:18:01 -08:00
|
|
|
|
core::Logger::getInstance().info("Composite: UNKNOWN region for '",
|
|
|
|
|
|
layerPaths[layer], "', centering at (", dstX, ",", dstY, ")");
|
|
|
|
|
|
blitOverlay(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
|
continue;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-17 03:18:01 -08:00
|
|
|
|
// Scale coordinates from 256-base to actual canvas
|
|
|
|
|
|
dstX *= coordScale;
|
|
|
|
|
|
dstY *= coordScale;
|
|
|
|
|
|
|
|
|
|
|
|
// If overlay is 256-base sized but canvas is larger, scale the overlay up
|
|
|
|
|
|
int expectedW = expectedW256 * coordScale;
|
|
|
|
|
|
int expectedH = expectedH256 * coordScale;
|
|
|
|
|
|
bool needsScale = (coordScale > 1 &&
|
|
|
|
|
|
overlay.width == expectedW256 && overlay.height == expectedH256);
|
|
|
|
|
|
|
|
|
|
|
|
core::Logger::getInstance().info("Composite: placing '", layerPaths[layer],
|
|
|
|
|
|
"' (", overlay.width, "x", overlay.height,
|
|
|
|
|
|
") at (", dstX, ",", dstY, ") on ", width, "x", height,
|
|
|
|
|
|
" expected=", expectedW, "x", expectedH,
|
|
|
|
|
|
needsScale ? " [SCALING]" : "");
|
|
|
|
|
|
|
|
|
|
|
|
if (needsScale) {
|
|
|
|
|
|
blitOverlayScaledN(composite, width, height, overlay, dstX, dstY, coordScale);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
blitOverlay(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Debug: dump composite to /tmp for visual inspection
|
|
|
|
|
|
{
|
|
|
|
|
|
std::string dumpPath = "/tmp/wowee_composite_debug_" +
|
|
|
|
|
|
std::to_string(width) + "x" + std::to_string(height) + ".raw";
|
|
|
|
|
|
std::ofstream dump(dumpPath, std::ios::binary);
|
|
|
|
|
|
if (dump) {
|
|
|
|
|
|
dump.write(reinterpret_cast<const char*>(composite.data()),
|
|
|
|
|
|
static_cast<std::streamsize>(composite.size()));
|
|
|
|
|
|
core::Logger::getInstance().info("Composite debug dump: ", dumpPath,
|
|
|
|
|
|
" (", width, "x", height, ", ", composite.size(), " bytes)");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Upload composite to GPU
|
|
|
|
|
|
GLuint texId;
|
|
|
|
|
|
glGenTextures(1, &texId);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, texId);
|
|
|
|
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, composite.data());
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
|
|
|
|
|
|
glGenerateMipmap(GL_TEXTURE_2D);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
applyAnisotropicFiltering();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
|
|
|
|
|
|
|
|
|
|
core::Logger::getInstance().info("Composite texture created: ", width, "x", height, " from ", layerPaths.size(), " layers");
|
|
|
|
|
|
return texId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
void CharacterRenderer::clearCompositeCache() {
|
2026-02-16 00:19:07 -08:00
|
|
|
|
// Just clear the lookup map so next compositeWithRegions() creates fresh textures.
|
|
|
|
|
|
// Don't delete GPU textures — they may still be referenced by models or instances.
|
|
|
|
|
|
// Orphaned textures will be cleaned up when their model/instance is destroyed.
|
2026-02-15 20:53:01 -08:00
|
|
|
|
compositeCache_.clear();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
|
|
|
|
|
|
const std::vector<std::string>& baseLayers,
|
|
|
|
|
|
const std::vector<std::pair<int, std::string>>& regionLayers) {
|
2026-02-14 15:48:58 -08:00
|
|
|
|
// Build cache key from all inputs to avoid redundant compositing
|
|
|
|
|
|
std::string cacheKey = basePath;
|
|
|
|
|
|
for (const auto& bl : baseLayers) { cacheKey += '|'; cacheKey += bl; }
|
|
|
|
|
|
cacheKey += '#';
|
|
|
|
|
|
for (const auto& rl : regionLayers) {
|
|
|
|
|
|
cacheKey += std::to_string(rl.first);
|
|
|
|
|
|
cacheKey += ':';
|
|
|
|
|
|
cacheKey += rl.second;
|
|
|
|
|
|
cacheKey += ',';
|
|
|
|
|
|
}
|
|
|
|
|
|
auto cacheIt = compositeCache_.find(cacheKey);
|
|
|
|
|
|
if (cacheIt != compositeCache_.end() && cacheIt->second != 0) {
|
|
|
|
|
|
return cacheIt->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Region index → pixel coordinates on the 256x256 base atlas
|
|
|
|
|
|
// These are scaled up by (width/256, height/256) for larger textures (512x512, 1024x1024)
|
|
|
|
|
|
static const int regionCoords256[][2] = {
|
2026-02-02 12:24:50 -08:00
|
|
|
|
{ 0, 0 }, // 0 = ArmUpper
|
2026-02-15 20:53:01 -08:00
|
|
|
|
{ 0, 64 }, // 1 = ArmLower
|
|
|
|
|
|
{ 0, 128 }, // 2 = Hand
|
|
|
|
|
|
{ 128, 0 }, // 3 = TorsoUpper
|
|
|
|
|
|
{ 128, 64 }, // 4 = TorsoLower
|
|
|
|
|
|
{ 128, 96 }, // 5 = LegUpper
|
|
|
|
|
|
{ 128, 160 }, // 6 = LegLower
|
|
|
|
|
|
{ 128, 224 }, // 7 = Foot
|
2026-02-02 12:24:50 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// First, build base skin + underwear using existing compositeTextures
|
|
|
|
|
|
std::vector<std::string> layers;
|
|
|
|
|
|
layers.push_back(basePath);
|
|
|
|
|
|
for (const auto& ul : baseLayers) {
|
|
|
|
|
|
layers.push_back(ul);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Load base composite into CPU buffer
|
|
|
|
|
|
if (!assetManager || !assetManager->isInitialized()) {
|
|
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto base = assetManager->loadTexture(basePath);
|
|
|
|
|
|
if (!base.isValid()) {
|
|
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 23:15:57 -08:00
|
|
|
|
std::vector<uint8_t> composite;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
int width = base.width;
|
|
|
|
|
|
int height = base.height;
|
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-05 23:15:57 -08:00
|
|
|
|
// If base texture is 256x256 (e.g., baked NPC texture), upscale to 512x512
|
|
|
|
|
|
// so equipment regions can be composited at correct coordinates
|
|
|
|
|
|
if (width == 256 && height == 256 && !regionLayers.empty()) {
|
|
|
|
|
|
width = 512;
|
|
|
|
|
|
height = 512;
|
|
|
|
|
|
composite.resize(width * height * 4);
|
|
|
|
|
|
// Simple 2x nearest-neighbor upscale
|
|
|
|
|
|
for (int y = 0; y < 512; y++) {
|
|
|
|
|
|
for (int x = 0; x < 512; x++) {
|
|
|
|
|
|
int srcX = x / 2;
|
|
|
|
|
|
int srcY = y / 2;
|
|
|
|
|
|
int srcIdx = (srcY * 256 + srcX) * 4;
|
|
|
|
|
|
int dstIdx = (y * 512 + x) * 4;
|
|
|
|
|
|
composite[dstIdx + 0] = base.data[srcIdx + 0];
|
|
|
|
|
|
composite[dstIdx + 1] = base.data[srcIdx + 1];
|
|
|
|
|
|
composite[dstIdx + 2] = base.data[srcIdx + 2];
|
|
|
|
|
|
composite[dstIdx + 3] = base.data[srcIdx + 3];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-15 14:00:41 -08:00
|
|
|
|
core::Logger::getInstance().debug("compositeWithRegions: upscaled 256x256 to 512x512");
|
2026-02-05 23:15:57 -08:00
|
|
|
|
} else {
|
|
|
|
|
|
composite = base.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
|
// Blend face + underwear overlays
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// If we upscaled from 256→512, scale coords and texels with blitOverlayScaled2x.
|
|
|
|
|
|
// For native 512/1024 textures, face overlays are full atlas size (hit width==width branch).
|
2026-02-15 12:53:15 -08:00
|
|
|
|
bool upscaled = (base.width == 256 && base.height == 256 && width == 512);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
for (const auto& ul : baseLayers) {
|
|
|
|
|
|
if (ul.empty()) continue;
|
|
|
|
|
|
auto overlay = assetManager->loadTexture(ul);
|
|
|
|
|
|
if (!overlay.isValid()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
if (overlay.width == width && overlay.height == height) {
|
|
|
|
|
|
blitOverlay(composite, width, height, overlay, 0, 0);
|
|
|
|
|
|
} else {
|
2026-02-15 12:53:15 -08:00
|
|
|
|
// WoW 256-scale atlas coordinates (from CharComponentTextureSections)
|
2026-02-02 12:24:50 -08:00
|
|
|
|
int dstX = 0, dstY = 0;
|
|
|
|
|
|
std::string pathLower = ul;
|
|
|
|
|
|
for (auto& c : pathLower) c = std::tolower(c);
|
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Scale factor from 256-base coordinates to actual canvas size
|
|
|
|
|
|
int coordScale = width / 256;
|
|
|
|
|
|
if (coordScale < 1) coordScale = 1;
|
|
|
|
|
|
bool useScale = true;
|
|
|
|
|
|
|
2026-02-15 12:53:15 -08:00
|
|
|
|
if (pathLower.find("faceupper") != std::string::npos) {
|
|
|
|
|
|
dstX = 0; dstY = 160;
|
|
|
|
|
|
} else if (pathLower.find("facelower") != std::string::npos) {
|
|
|
|
|
|
dstX = 0; dstY = 192;
|
|
|
|
|
|
} else if (pathLower.find("pelvis") != std::string::npos) {
|
|
|
|
|
|
dstX = 128; dstY = 96;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("torso") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
|
dstX = 128; dstY = 0;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("armupper") != std::string::npos) {
|
|
|
|
|
|
dstX = 0; dstY = 0;
|
|
|
|
|
|
} else if (pathLower.find("armlower") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
|
dstX = 0; dstY = 64;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("hand") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
|
dstX = 0; dstY = 128;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("foot") != std::string::npos || pathLower.find("feet") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
|
dstX = 128; dstY = 224;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) {
|
2026-02-15 12:53:15 -08:00
|
|
|
|
dstX = 128; dstY = 160;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} else {
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Fallback: center overlay on canvas (already in canvas coords)
|
|
|
|
|
|
dstX = (width - overlay.width) / 2;
|
|
|
|
|
|
dstY = (height - overlay.height) / 2;
|
|
|
|
|
|
useScale = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (useScale) {
|
|
|
|
|
|
dstX *= coordScale;
|
|
|
|
|
|
dstY *= coordScale;
|
2026-02-15 12:53:15 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (upscaled) {
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Overlay is 256-base sized, needs 2x texel scaling for 512 canvas
|
2026-02-15 12:53:15 -08:00
|
|
|
|
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
blitOverlay(composite, width, height, overlay, dstX, dstY);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Expected region sizes on the 256x256 base atlas (scaled like coords)
|
|
|
|
|
|
static const int regionSizes256[][2] = {
|
|
|
|
|
|
{ 128, 64 }, // 0 = ArmUpper
|
|
|
|
|
|
{ 128, 64 }, // 1 = ArmLower
|
|
|
|
|
|
{ 128, 32 }, // 2 = Hand
|
|
|
|
|
|
{ 128, 64 }, // 3 = TorsoUpper
|
|
|
|
|
|
{ 128, 32 }, // 4 = TorsoLower
|
|
|
|
|
|
{ 128, 64 }, // 5 = LegUpper
|
|
|
|
|
|
{ 128, 64 }, // 6 = LegLower
|
|
|
|
|
|
{ 128, 32 }, // 7 = Foot
|
2026-02-02 12:24:50 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Scale factor from 256-base to actual texture size
|
|
|
|
|
|
int scaleX = width / 256;
|
|
|
|
|
|
int scaleY = height / 256;
|
|
|
|
|
|
if (scaleX < 1) scaleX = 1;
|
|
|
|
|
|
if (scaleY < 1) scaleY = 1;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Now blit equipment region textures at explicit coordinates
|
|
|
|
|
|
for (const auto& rl : regionLayers) {
|
|
|
|
|
|
int regionIdx = rl.first;
|
|
|
|
|
|
if (regionIdx < 0 || regionIdx >= 8) continue;
|
|
|
|
|
|
|
|
|
|
|
|
auto overlay = assetManager->loadTexture(rl.second);
|
|
|
|
|
|
if (!overlay.isValid()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("compositeWithRegions: failed to load ", rl.second);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
int dstX = regionCoords256[regionIdx][0] * scaleX;
|
|
|
|
|
|
int dstY = regionCoords256[regionIdx][1] * scaleY;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Expected full-resolution size for this region at current atlas scale
|
|
|
|
|
|
int expectedW = regionSizes256[regionIdx][0] * scaleX;
|
|
|
|
|
|
int expectedH = regionSizes256[regionIdx][1] * scaleY;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (overlay.width * 2 == expectedW && overlay.height * 2 == expectedH) {
|
|
|
|
|
|
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
blitOverlay(composite, width, height, overlay, dstX, dstY);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
|
core::Logger::getInstance().debug("compositeWithRegions: region ", regionIdx,
|
2026-02-02 12:24:50 -08:00
|
|
|
|
" at (", dstX, ",", dstY, ") ", overlay.width, "x", overlay.height, " from ", rl.second);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Upload to GPU
|
|
|
|
|
|
GLuint texId;
|
|
|
|
|
|
glGenTextures(1, &texId);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, texId);
|
|
|
|
|
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, composite.data());
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
|
|
|
|
|
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
|
|
|
|
|
|
glGenerateMipmap(GL_TEXTURE_2D);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
applyAnisotropicFiltering();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
glBindTexture(GL_TEXTURE_2D, 0);
|
|
|
|
|
|
|
2026-02-15 14:00:41 -08:00
|
|
|
|
core::Logger::getInstance().debug("compositeWithRegions: created ", width, "x", height,
|
2026-02-02 12:24:50 -08:00
|
|
|
|
" texture with ", regionLayers.size(), " equipment regions");
|
2026-02-14 15:48:58 -08:00
|
|
|
|
compositeCache_[cacheKey] = texId;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
return texId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::setModelTexture(uint32_t modelId, uint32_t textureSlot, GLuint textureId) {
|
|
|
|
|
|
auto it = models.find(modelId);
|
|
|
|
|
|
if (it == models.end()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("setModelTexture: model ", modelId, " not found");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto& gpuModel = it->second;
|
|
|
|
|
|
if (textureSlot >= gpuModel.textureIds.size()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("setModelTexture: slot ", textureSlot, " out of range (", gpuModel.textureIds.size(), " textures)");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Delete old texture if it's not shared and not in the texture cache
|
|
|
|
|
|
GLuint oldTex = gpuModel.textureIds[textureSlot];
|
|
|
|
|
|
if (oldTex && oldTex != whiteTexture) {
|
|
|
|
|
|
bool cached = false;
|
|
|
|
|
|
for (const auto& [k, v] : textureCache) {
|
2026-02-12 16:29:36 -08:00
|
|
|
|
if (v.id == oldTex) { cached = true; break; }
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
if (!cached) {
|
|
|
|
|
|
glDeleteTextures(1, &oldTex);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
gpuModel.textureIds[textureSlot] = textureId;
|
2026-02-11 22:27:02 -08:00
|
|
|
|
core::Logger::getInstance().debug("Replaced model ", modelId, " texture slot ", textureSlot, " with composited texture");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::resetModelTexture(uint32_t modelId, uint32_t textureSlot) {
|
|
|
|
|
|
setModelTexture(modelId, textureSlot, whiteTexture);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) {
|
|
|
|
|
|
if (!model.isValid()) {
|
|
|
|
|
|
core::Logger::getInstance().error("Cannot load invalid M2 model");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (models.find(id) != models.end()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("Model ID ", id, " already loaded, replacing");
|
|
|
|
|
|
auto& old = models[id];
|
|
|
|
|
|
if (old.vao) {
|
|
|
|
|
|
glDeleteVertexArrays(1, &old.vao);
|
|
|
|
|
|
glDeleteBuffers(1, &old.vbo);
|
|
|
|
|
|
glDeleteBuffers(1, &old.ebo);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
M2ModelGPU gpuModel;
|
|
|
|
|
|
gpuModel.data = model;
|
|
|
|
|
|
|
|
|
|
|
|
// Setup GPU buffers
|
|
|
|
|
|
setupModelBuffers(gpuModel);
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate bind pose
|
|
|
|
|
|
calculateBindPose(gpuModel);
|
|
|
|
|
|
|
|
|
|
|
|
// Load textures from model
|
|
|
|
|
|
for (const auto& tex : model.textures) {
|
|
|
|
|
|
GLuint texId = loadTexture(tex.filename);
|
|
|
|
|
|
gpuModel.textureIds.push_back(texId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
models[id] = std::move(gpuModel);
|
|
|
|
|
|
|
2026-02-11 22:27:02 -08:00
|
|
|
|
core::Logger::getInstance().debug("Loaded M2 model ", id, " (", model.vertices.size(),
|
|
|
|
|
|
" verts, ", model.bones.size(), " bones, ", model.sequences.size(),
|
|
|
|
|
|
" anims, ", model.textures.size(), " textures)");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::setupModelBuffers(M2ModelGPU& gpuModel) {
|
|
|
|
|
|
auto& model = gpuModel.data;
|
|
|
|
|
|
|
|
|
|
|
|
glGenVertexArrays(1, &gpuModel.vao);
|
|
|
|
|
|
glGenBuffers(1, &gpuModel.vbo);
|
|
|
|
|
|
glGenBuffers(1, &gpuModel.ebo);
|
|
|
|
|
|
|
|
|
|
|
|
glBindVertexArray(gpuModel.vao);
|
|
|
|
|
|
|
|
|
|
|
|
// Interleaved vertex data
|
|
|
|
|
|
glBindBuffer(GL_ARRAY_BUFFER, gpuModel.vbo);
|
|
|
|
|
|
glBufferData(GL_ARRAY_BUFFER, model.vertices.size() * sizeof(pipeline::M2Vertex),
|
|
|
|
|
|
model.vertices.data(), GL_STATIC_DRAW);
|
|
|
|
|
|
|
|
|
|
|
|
// Position
|
|
|
|
|
|
glEnableVertexAttribArray(0);
|
|
|
|
|
|
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex),
|
|
|
|
|
|
(void*)offsetof(pipeline::M2Vertex, position));
|
|
|
|
|
|
|
|
|
|
|
|
// Bone weights (normalize uint8 to float)
|
|
|
|
|
|
glEnableVertexAttribArray(1);
|
|
|
|
|
|
glVertexAttribPointer(1, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(pipeline::M2Vertex),
|
|
|
|
|
|
(void*)offsetof(pipeline::M2Vertex, boneWeights));
|
|
|
|
|
|
|
|
|
|
|
|
// Bone indices
|
|
|
|
|
|
glEnableVertexAttribArray(2);
|
|
|
|
|
|
glVertexAttribIPointer(2, 4, GL_UNSIGNED_BYTE, sizeof(pipeline::M2Vertex),
|
|
|
|
|
|
(void*)offsetof(pipeline::M2Vertex, boneIndices));
|
|
|
|
|
|
|
|
|
|
|
|
// Normal
|
|
|
|
|
|
glEnableVertexAttribArray(3);
|
|
|
|
|
|
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex),
|
|
|
|
|
|
(void*)offsetof(pipeline::M2Vertex, normal));
|
|
|
|
|
|
|
|
|
|
|
|
// TexCoord (first UV set)
|
|
|
|
|
|
glEnableVertexAttribArray(4);
|
|
|
|
|
|
glVertexAttribPointer(4, 2, GL_FLOAT, GL_FALSE, sizeof(pipeline::M2Vertex),
|
|
|
|
|
|
(void*)offsetof(pipeline::M2Vertex, texCoords));
|
|
|
|
|
|
|
|
|
|
|
|
// Index buffer
|
|
|
|
|
|
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, gpuModel.ebo);
|
|
|
|
|
|
glBufferData(GL_ELEMENT_ARRAY_BUFFER, model.indices.size() * sizeof(uint16_t),
|
|
|
|
|
|
model.indices.data(), GL_STATIC_DRAW);
|
|
|
|
|
|
|
|
|
|
|
|
glBindVertexArray(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::calculateBindPose(M2ModelGPU& gpuModel) {
|
|
|
|
|
|
auto& bones = gpuModel.data.bones;
|
|
|
|
|
|
size_t numBones = bones.size();
|
|
|
|
|
|
gpuModel.bindPose.resize(numBones);
|
|
|
|
|
|
|
|
|
|
|
|
// Compute full hierarchical rest pose, then invert.
|
|
|
|
|
|
// Each bone's rest position is T(pivot), composed with its parent chain.
|
|
|
|
|
|
std::vector<glm::mat4> restPose(numBones);
|
|
|
|
|
|
for (size_t i = 0; i < numBones; i++) {
|
|
|
|
|
|
glm::mat4 local = glm::translate(glm::mat4(1.0f), bones[i].pivot);
|
|
|
|
|
|
if (bones[i].parentBone >= 0 && static_cast<size_t>(bones[i].parentBone) < numBones) {
|
|
|
|
|
|
restPose[i] = restPose[bones[i].parentBone] * local;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
restPose[i] = local;
|
|
|
|
|
|
}
|
|
|
|
|
|
gpuModel.bindPose[i] = glm::inverse(restPose[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t CharacterRenderer::createInstance(uint32_t modelId, const glm::vec3& position,
|
|
|
|
|
|
const glm::vec3& rotation, float scale) {
|
|
|
|
|
|
if (models.find(modelId) == models.end()) {
|
|
|
|
|
|
core::Logger::getInstance().error("Cannot create instance: model ", modelId, " not loaded");
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CharacterInstance instance;
|
|
|
|
|
|
instance.id = nextInstanceId++;
|
|
|
|
|
|
instance.modelId = modelId;
|
|
|
|
|
|
instance.position = position;
|
|
|
|
|
|
instance.rotation = rotation;
|
|
|
|
|
|
instance.scale = scale;
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize bone matrices to identity
|
|
|
|
|
|
auto& model = models[modelId].data;
|
|
|
|
|
|
instance.boneMatrices.resize(std::max(static_cast<size_t>(1), model.bones.size()), glm::mat4(1.0f));
|
|
|
|
|
|
|
|
|
|
|
|
instances[instance.id] = instance;
|
|
|
|
|
|
return instance.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, bool loop) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("Cannot play animation: instance ", instanceId, " not found");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto& instance = it->second;
|
|
|
|
|
|
auto& model = models[instance.modelId].data;
|
|
|
|
|
|
|
2026-02-06 16:47:07 -08:00
|
|
|
|
// Track death state for preventing movement while dead
|
|
|
|
|
|
if (animationId == 1) {
|
|
|
|
|
|
instance.isDead = true;
|
|
|
|
|
|
} else if (instance.isDead && animationId == 0) {
|
|
|
|
|
|
instance.isDead = false; // Respawned
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Find animation sequence index by ID
|
|
|
|
|
|
instance.currentAnimationId = animationId;
|
|
|
|
|
|
instance.currentSequenceIndex = -1;
|
|
|
|
|
|
instance.animationTime = 0.0f;
|
|
|
|
|
|
instance.animationLoop = loop;
|
|
|
|
|
|
|
|
|
|
|
|
for (size_t i = 0; i < model.sequences.size(); i++) {
|
|
|
|
|
|
if (model.sequences[i].id == animationId) {
|
|
|
|
|
|
instance.currentSequenceIndex = static_cast<int>(i);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (instance.currentSequenceIndex < 0) {
|
|
|
|
|
|
// Fall back to first sequence
|
|
|
|
|
|
if (!model.sequences.empty()) {
|
|
|
|
|
|
instance.currentSequenceIndex = 0;
|
|
|
|
|
|
instance.currentAnimationId = model.sequences[0].id;
|
|
|
|
|
|
}
|
2026-02-10 19:30:45 -08:00
|
|
|
|
|
|
|
|
|
|
// Only log missing animation once per model (reduce spam)
|
|
|
|
|
|
static std::unordered_map<uint32_t, std::unordered_set<uint32_t>> loggedMissingAnims;
|
|
|
|
|
|
uint32_t modelId = instance.modelId; // Use modelId as identifier
|
|
|
|
|
|
if (loggedMissingAnims[modelId].insert(animationId).second) {
|
|
|
|
|
|
// First time seeing this missing animation for this model
|
|
|
|
|
|
LOG_WARNING("Animation ", animationId, " not found in model ", modelId, ", using default");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
|
|
|
|
|
|
// Distance culling for animation updates (150 unit radius)
|
|
|
|
|
|
const float animUpdateRadiusSq = 150.0f * 150.0f;
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
// Update fade-in opacity
|
|
|
|
|
|
for (auto& [id, inst] : instances) {
|
|
|
|
|
|
if (inst.fadeInDuration > 0.0f && inst.opacity < 1.0f) {
|
|
|
|
|
|
inst.fadeInTime += deltaTime;
|
|
|
|
|
|
inst.opacity = std::min(1.0f, inst.fadeInTime / inst.fadeInDuration);
|
|
|
|
|
|
if (inst.opacity >= 1.0f) {
|
|
|
|
|
|
inst.fadeInDuration = 0.0f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Interpolate creature movement
|
|
|
|
|
|
for (auto& [id, inst] : instances) {
|
|
|
|
|
|
if (inst.isMoving) {
|
|
|
|
|
|
inst.moveElapsed += deltaTime;
|
|
|
|
|
|
float t = inst.moveElapsed / inst.moveDuration;
|
|
|
|
|
|
if (t >= 1.0f) {
|
|
|
|
|
|
inst.position = inst.moveEnd;
|
|
|
|
|
|
inst.isMoving = false;
|
|
|
|
|
|
// Return to idle when movement completes
|
2026-02-18 03:53:53 -08:00
|
|
|
|
if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) {
|
2026-02-06 13:47:03 -08:00
|
|
|
|
playAnimation(id, 0, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
inst.position = glm::mix(inst.moveStart, inst.moveEnd, t);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Only update animations for nearby characters (performance optimization)
|
|
|
|
|
|
// Collect instances that need updates
|
|
|
|
|
|
std::vector<std::reference_wrapper<CharacterInstance>> toUpdate;
|
|
|
|
|
|
toUpdate.reserve(instances.size());
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
for (auto& pair : instances) {
|
2026-02-10 19:30:45 -08:00
|
|
|
|
float distSq = glm::distance2(pair.second.position, cameraPos);
|
|
|
|
|
|
if (distSq < animUpdateRadiusSq) {
|
|
|
|
|
|
toUpdate.push_back(std::ref(pair.second));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int updatedCount = toUpdate.size();
|
|
|
|
|
|
|
|
|
|
|
|
// Thread bone calculations if we have many characters (4+)
|
|
|
|
|
|
if (updatedCount >= 4) {
|
|
|
|
|
|
std::vector<std::future<void>> futures;
|
|
|
|
|
|
futures.reserve(updatedCount);
|
|
|
|
|
|
|
|
|
|
|
|
for (auto& instRef : toUpdate) {
|
|
|
|
|
|
futures.push_back(std::async(std::launch::async, [this, &instRef, deltaTime]() {
|
|
|
|
|
|
updateAnimation(instRef.get(), deltaTime);
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for all to complete
|
|
|
|
|
|
for (auto& f : futures) {
|
|
|
|
|
|
f.get();
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Sequential for small counts (avoid thread overhead)
|
|
|
|
|
|
for (auto& instRef : toUpdate) {
|
|
|
|
|
|
updateAnimation(instRef.get(), deltaTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static int logCounter = 0;
|
|
|
|
|
|
if (++logCounter >= 300) { // Log every 10 seconds at 30fps
|
2026-02-11 21:14:35 -08:00
|
|
|
|
LOG_DEBUG("CharacterRenderer: ", updatedCount, "/", instances.size(), " instances updated (",
|
2026-02-10 19:30:45 -08:00
|
|
|
|
instances.size() - updatedCount, " culled)");
|
|
|
|
|
|
logCounter = 0;
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update weapon attachment transforms (after all bone matrices are computed)
|
|
|
|
|
|
for (auto& pair : instances) {
|
|
|
|
|
|
auto& instance = pair.second;
|
|
|
|
|
|
if (instance.weaponAttachments.empty()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
glm::mat4 charModelMat = instance.hasOverrideModelMatrix
|
|
|
|
|
|
? instance.overrideModelMatrix
|
|
|
|
|
|
: getModelMatrix(instance);
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& wa : instance.weaponAttachments) {
|
|
|
|
|
|
auto weapIt = instances.find(wa.weaponInstanceId);
|
|
|
|
|
|
if (weapIt == instances.end()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// Get the bone matrix for the attachment bone
|
|
|
|
|
|
glm::mat4 boneMat(1.0f);
|
|
|
|
|
|
if (wa.boneIndex < instance.boneMatrices.size()) {
|
|
|
|
|
|
boneMat = instance.boneMatrices[wa.boneIndex];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Weapon model matrix = character model * bone transform * offset translation
|
|
|
|
|
|
weapIt->second.overrideModelMatrix =
|
|
|
|
|
|
charModelMat * boneMat * glm::translate(glm::mat4(1.0f), wa.offset);
|
|
|
|
|
|
weapIt->second.hasOverrideModelMatrix = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::updateAnimation(CharacterInstance& instance, float deltaTime) {
|
|
|
|
|
|
auto& model = models[instance.modelId].data;
|
|
|
|
|
|
|
|
|
|
|
|
if (model.sequences.empty()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Resolve sequence index if not set
|
|
|
|
|
|
if (instance.currentSequenceIndex < 0) {
|
|
|
|
|
|
instance.currentSequenceIndex = 0;
|
|
|
|
|
|
instance.currentAnimationId = model.sequences[0].id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const auto& sequence = model.sequences[instance.currentSequenceIndex];
|
|
|
|
|
|
|
|
|
|
|
|
// Update animation time (convert to milliseconds)
|
|
|
|
|
|
instance.animationTime += deltaTime * 1000.0f;
|
|
|
|
|
|
|
|
|
|
|
|
if (sequence.duration > 0 && instance.animationTime >= static_cast<float>(sequence.duration)) {
|
|
|
|
|
|
if (instance.animationLoop) {
|
|
|
|
|
|
instance.animationTime = std::fmod(instance.animationTime, static_cast<float>(sequence.duration));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
instance.animationTime = static_cast<float>(sequence.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update bone matrices
|
|
|
|
|
|
calculateBoneMatrices(instance);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Keyframe interpolation helpers ---
|
|
|
|
|
|
|
|
|
|
|
|
int CharacterRenderer::findKeyframeIndex(const std::vector<uint32_t>& timestamps, float time) {
|
|
|
|
|
|
if (timestamps.empty()) return -1;
|
|
|
|
|
|
if (timestamps.size() == 1) return 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Binary search for the keyframe bracket
|
|
|
|
|
|
for (size_t i = 0; i < timestamps.size() - 1; i++) {
|
|
|
|
|
|
if (time < static_cast<float>(timestamps[i + 1])) {
|
|
|
|
|
|
return static_cast<int>(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return static_cast<int>(timestamps.size() - 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glm::vec3 CharacterRenderer::interpolateVec3(const pipeline::M2AnimationTrack& track,
|
|
|
|
|
|
int seqIdx, float time, const glm::vec3& defaultVal) {
|
|
|
|
|
|
if (!track.hasData()) return defaultVal;
|
|
|
|
|
|
if (seqIdx < 0 || seqIdx >= static_cast<int>(track.sequences.size())) return defaultVal;
|
|
|
|
|
|
|
|
|
|
|
|
const auto& keys = track.sequences[seqIdx];
|
|
|
|
|
|
if (keys.timestamps.empty() || keys.vec3Values.empty()) return defaultVal;
|
|
|
|
|
|
|
|
|
|
|
|
auto safeVec3 = [&](const glm::vec3& v) -> glm::vec3 {
|
|
|
|
|
|
if (std::isnan(v.x) || std::isnan(v.y) || std::isnan(v.z)) return defaultVal;
|
|
|
|
|
|
return v;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (keys.vec3Values.size() == 1) return safeVec3(keys.vec3Values[0]);
|
|
|
|
|
|
|
|
|
|
|
|
int idx = findKeyframeIndex(keys.timestamps, time);
|
|
|
|
|
|
if (idx < 0) return defaultVal;
|
|
|
|
|
|
|
|
|
|
|
|
size_t i0 = static_cast<size_t>(idx);
|
|
|
|
|
|
size_t i1 = std::min(i0 + 1, keys.vec3Values.size() - 1);
|
|
|
|
|
|
|
|
|
|
|
|
if (i0 == i1) return safeVec3(keys.vec3Values[i0]);
|
|
|
|
|
|
|
|
|
|
|
|
float t0 = static_cast<float>(keys.timestamps[i0]);
|
|
|
|
|
|
float t1 = static_cast<float>(keys.timestamps[i1]);
|
|
|
|
|
|
float duration = t1 - t0;
|
|
|
|
|
|
float t = (duration > 0.0f) ? glm::clamp((time - t0) / duration, 0.0f, 1.0f) : 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
return safeVec3(glm::mix(keys.vec3Values[i0], keys.vec3Values[i1], t));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glm::quat CharacterRenderer::interpolateQuat(const pipeline::M2AnimationTrack& track,
|
|
|
|
|
|
int seqIdx, float time) {
|
|
|
|
|
|
glm::quat identity(1.0f, 0.0f, 0.0f, 0.0f);
|
|
|
|
|
|
if (!track.hasData()) return identity;
|
|
|
|
|
|
if (seqIdx < 0 || seqIdx >= static_cast<int>(track.sequences.size())) return identity;
|
|
|
|
|
|
|
|
|
|
|
|
const auto& keys = track.sequences[seqIdx];
|
|
|
|
|
|
if (keys.timestamps.empty() || keys.quatValues.empty()) return identity;
|
|
|
|
|
|
|
|
|
|
|
|
auto safeQuat = [&](const glm::quat& q) -> glm::quat {
|
|
|
|
|
|
float len = glm::length(q);
|
|
|
|
|
|
if (len < 0.001f || std::isnan(len)) return identity;
|
|
|
|
|
|
return q;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (keys.quatValues.size() == 1) return safeQuat(keys.quatValues[0]);
|
|
|
|
|
|
|
|
|
|
|
|
int idx = findKeyframeIndex(keys.timestamps, time);
|
|
|
|
|
|
if (idx < 0) return identity;
|
|
|
|
|
|
|
|
|
|
|
|
size_t i0 = static_cast<size_t>(idx);
|
|
|
|
|
|
size_t i1 = std::min(i0 + 1, keys.quatValues.size() - 1);
|
|
|
|
|
|
|
|
|
|
|
|
if (i0 == i1) return safeQuat(keys.quatValues[i0]);
|
|
|
|
|
|
|
|
|
|
|
|
glm::quat q0 = safeQuat(keys.quatValues[i0]);
|
|
|
|
|
|
glm::quat q1 = safeQuat(keys.quatValues[i1]);
|
|
|
|
|
|
|
|
|
|
|
|
float t0 = static_cast<float>(keys.timestamps[i0]);
|
|
|
|
|
|
float t1 = static_cast<float>(keys.timestamps[i1]);
|
|
|
|
|
|
float duration = t1 - t0;
|
|
|
|
|
|
float t = (duration > 0.0f) ? glm::clamp((time - t0) / duration, 0.0f, 1.0f) : 0.0f;
|
|
|
|
|
|
|
|
|
|
|
|
return glm::slerp(q0, q1, t);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Bone transform calculation ---
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::calculateBoneMatrices(CharacterInstance& instance) {
|
|
|
|
|
|
auto& model = models[instance.modelId].data;
|
|
|
|
|
|
|
|
|
|
|
|
if (model.bones.empty()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
size_t numBones = model.bones.size();
|
|
|
|
|
|
instance.boneMatrices.resize(numBones);
|
|
|
|
|
|
|
|
|
|
|
|
static bool dumpedOnce = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (size_t i = 0; i < numBones; i++) {
|
|
|
|
|
|
const auto& bone = model.bones[i];
|
|
|
|
|
|
|
|
|
|
|
|
// Local transform includes pivot bracket: T(pivot)*T*R*S*T(-pivot)
|
|
|
|
|
|
// At rest this is identity, so no separate bind pose is needed
|
|
|
|
|
|
glm::mat4 localTransform = getBoneTransform(bone, instance.animationTime, instance.currentSequenceIndex);
|
|
|
|
|
|
|
|
|
|
|
|
// Debug: dump first frame bone data
|
|
|
|
|
|
if (!dumpedOnce && i < 5) {
|
|
|
|
|
|
glm::vec3 t = interpolateVec3(bone.translation, instance.currentSequenceIndex, instance.animationTime, glm::vec3(0.0f));
|
|
|
|
|
|
glm::quat r = interpolateQuat(bone.rotation, instance.currentSequenceIndex, instance.animationTime);
|
|
|
|
|
|
glm::vec3 s = interpolateVec3(bone.scale, instance.currentSequenceIndex, instance.animationTime, glm::vec3(1.0f));
|
|
|
|
|
|
core::Logger::getInstance().info("Bone ", i, " parent=", bone.parentBone,
|
|
|
|
|
|
" pivot=(", bone.pivot.x, ",", bone.pivot.y, ",", bone.pivot.z, ")",
|
|
|
|
|
|
" t=(", t.x, ",", t.y, ",", t.z, ")",
|
|
|
|
|
|
" r=(", r.w, ",", r.x, ",", r.y, ",", r.z, ")",
|
|
|
|
|
|
" s=(", s.x, ",", s.y, ",", s.z, ")",
|
|
|
|
|
|
" seqIdx=", instance.currentSequenceIndex);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Compose with parent
|
|
|
|
|
|
if (bone.parentBone >= 0 && static_cast<size_t>(bone.parentBone) < numBones) {
|
|
|
|
|
|
instance.boneMatrices[i] = instance.boneMatrices[bone.parentBone] * localTransform;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
instance.boneMatrices[i] = localTransform;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!dumpedOnce) {
|
|
|
|
|
|
dumpedOnce = true;
|
|
|
|
|
|
// Dump final matrix for bone 0
|
|
|
|
|
|
auto& m = instance.boneMatrices[0];
|
|
|
|
|
|
core::Logger::getInstance().info("Bone 0 final matrix row0=(", m[0][0], ",", m[1][0], ",", m[2][0], ",", m[3][0], ")");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glm::mat4 CharacterRenderer::getBoneTransform(const pipeline::M2Bone& bone, float time, int sequenceIndex) {
|
|
|
|
|
|
glm::vec3 translation = interpolateVec3(bone.translation, sequenceIndex, time, glm::vec3(0.0f));
|
|
|
|
|
|
glm::quat rotation = interpolateQuat(bone.rotation, sequenceIndex, time);
|
|
|
|
|
|
glm::vec3 scale = interpolateVec3(bone.scale, sequenceIndex, time, glm::vec3(1.0f));
|
|
|
|
|
|
|
|
|
|
|
|
// M2 bone transform: T(pivot) * T(trans) * R(rot) * S(scale) * T(-pivot)
|
|
|
|
|
|
// At rest (no animation): T(pivot) * I * I * I * T(-pivot) = identity
|
|
|
|
|
|
glm::mat4 transform = glm::translate(glm::mat4(1.0f), bone.pivot);
|
|
|
|
|
|
transform = glm::translate(transform, translation);
|
|
|
|
|
|
transform *= glm::toMat4(rotation);
|
|
|
|
|
|
transform = glm::scale(transform, scale);
|
|
|
|
|
|
transform = glm::translate(transform, -bone.pivot);
|
|
|
|
|
|
|
|
|
|
|
|
return transform;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Rendering ---
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, const glm::mat4& projection) {
|
|
|
|
|
|
if (instances.empty()) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glEnable(GL_DEPTH_TEST);
|
|
|
|
|
|
glDisable(GL_CULL_FACE); // M2 models have mixed winding; render both sides
|
2026-02-06 13:47:03 -08:00
|
|
|
|
glEnable(GL_BLEND);
|
|
|
|
|
|
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
characterShader->use();
|
|
|
|
|
|
characterShader->setUniform("uView", view);
|
|
|
|
|
|
characterShader->setUniform("uProjection", projection);
|
Implement WoW-accurate DBC-driven sky system with lore-faithful celestial bodies
Add SkySystem coordinator that follows WoW's actual architecture where skyboxes
are authoritative and procedural elements serve as fallbacks. Integrate lighting
system across all renderers (terrain, WMO, M2, character) with unified parameters.
Sky System:
- SkySystem coordinator manages skybox, celestial bodies, stars, clouds, lens flare
- Skybox is authoritative (baked stars from M2 models, procedural fallback only)
- skyboxHasStars flag gates procedural star rendering (prevents double-star bug)
Celestial Bodies (Lore-Accurate):
- Two moons: White Lady (30-day cycle, pale white) + Blue Child (27-day cycle, pale blue)
- Deterministic moon phases from server gameTime (not deltaTime toys)
- Sun positioning driven by LightingManager directionalDir (DBC-sourced)
- Camera-locked sky dome (translation ignored, rotation applied)
Lighting Integration:
- Apply LightingManager params to WMO, M2, character renderers
- Unified lighting: directional light, diffuse color, ambient color, fog
- Star occlusion by cloud density (70% weight) and fog density (30% weight)
Documentation:
- Add comprehensive SKY_SYSTEM.md technical guide
- Update MEMORY.md with sky system architecture and anti-patterns
- Update README.md with WoW-accurate descriptions
Critical design decisions:
- NO latitude-based star rotation (Azeroth not modeled as spherical planet)
- NO always-on procedural stars (skybox authority prevents zone identity loss)
- NO universal dual-moon setup (map-specific celestial configurations)
2026-02-10 14:36:17 -08:00
|
|
|
|
characterShader->setUniform("uLightDir", lightDir);
|
|
|
|
|
|
characterShader->setUniform("uLightColor", lightColor);
|
2026-02-04 15:28:47 -08:00
|
|
|
|
characterShader->setUniform("uSpecularIntensity", 0.5f);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
characterShader->setUniform("uViewPos", camera.getPosition());
|
|
|
|
|
|
|
2026-02-04 15:05:46 -08:00
|
|
|
|
// Fog
|
|
|
|
|
|
characterShader->setUniform("uFogColor", fogColor);
|
|
|
|
|
|
characterShader->setUniform("uFogStart", fogStart);
|
|
|
|
|
|
characterShader->setUniform("uFogEnd", fogEnd);
|
|
|
|
|
|
|
|
|
|
|
|
// Shadows
|
|
|
|
|
|
characterShader->setUniform("uShadowEnabled", shadowEnabled ? 1 : 0);
|
2026-02-04 16:22:18 -08:00
|
|
|
|
characterShader->setUniform("uShadowStrength", 0.65f);
|
2026-02-04 15:05:46 -08:00
|
|
|
|
if (shadowEnabled) {
|
|
|
|
|
|
characterShader->setUniform("uLightSpaceMatrix", lightSpaceMatrix);
|
|
|
|
|
|
glActiveTexture(GL_TEXTURE7);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, shadowDepthTex);
|
|
|
|
|
|
characterShader->setUniform("uShadowMap", 7);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
for (const auto& pair : instances) {
|
|
|
|
|
|
const auto& instance = pair.second;
|
2026-02-03 14:26:08 -08:00
|
|
|
|
|
|
|
|
|
|
// Skip invisible instances (e.g., player in first-person mode)
|
|
|
|
|
|
if (!instance.visible) continue;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
const auto& gpuModel = models[instance.modelId];
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
// Skip fully transparent instances
|
|
|
|
|
|
if (instance.opacity <= 0.0f) continue;
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
// Set model matrix (use override for weapon instances)
|
|
|
|
|
|
glm::mat4 modelMat = instance.hasOverrideModelMatrix
|
|
|
|
|
|
? instance.overrideModelMatrix
|
|
|
|
|
|
: getModelMatrix(instance);
|
|
|
|
|
|
characterShader->setUniform("uModel", modelMat);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
characterShader->setUniform("uOpacity", instance.opacity);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-02 23:03:45 -08:00
|
|
|
|
// Set bone matrices (upload all at once for performance)
|
2026-02-02 12:24:50 -08:00
|
|
|
|
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), MAX_BONES);
|
2026-02-02 23:03:45 -08:00
|
|
|
|
if (numBones > 0) {
|
|
|
|
|
|
characterShader->setUniformMatrixArray("uBones[0]", instance.boneMatrices.data(), numBones);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Bind VAO and draw
|
|
|
|
|
|
glBindVertexArray(gpuModel.vao);
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
if (!gpuModel.data.batches.empty()) {
|
|
|
|
|
|
bool applyGeosetFilter = !instance.activeGeosets.empty();
|
|
|
|
|
|
if (applyGeosetFilter) {
|
|
|
|
|
|
bool hasRenderableGeoset = false;
|
|
|
|
|
|
for (const auto& batch : gpuModel.data.batches) {
|
|
|
|
|
|
if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) {
|
|
|
|
|
|
hasRenderableGeoset = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-11 18:25:04 -08:00
|
|
|
|
if (!hasRenderableGeoset) {
|
|
|
|
|
|
static std::unordered_set<uint32_t> loggedGeosetFallback;
|
|
|
|
|
|
if (loggedGeosetFallback.insert(instance.id).second) {
|
|
|
|
|
|
LOG_WARNING("Geoset filter matched no batches for instance ",
|
|
|
|
|
|
instance.id, " (model ", instance.modelId,
|
|
|
|
|
|
"); rendering all batches as fallback");
|
|
|
|
|
|
}
|
2026-02-12 14:55:27 -08:00
|
|
|
|
applyGeosetFilter = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 19:40:54 -08:00
|
|
|
|
auto resolveBatchTexture = [&](const CharacterInstance& inst, const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint {
|
2026-02-12 14:55:27 -08:00
|
|
|
|
// A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex.
|
|
|
|
|
|
// We currently bind only a single texture, so pick the most appropriate one.
|
|
|
|
|
|
//
|
|
|
|
|
|
// This matters for hair: the first texture in the combo can be a mask/empty slot,
|
|
|
|
|
|
// causing the hair to render as solid white.
|
|
|
|
|
|
if (b.textureIndex == 0xFFFF) return whiteTexture;
|
|
|
|
|
|
if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture;
|
|
|
|
|
|
|
|
|
|
|
|
uint32_t comboCount = b.textureCount ? static_cast<uint32_t>(b.textureCount) : 1u;
|
|
|
|
|
|
comboCount = std::min<uint32_t>(comboCount, 8u);
|
|
|
|
|
|
|
|
|
|
|
|
struct Candidate { GLuint id; uint32_t type; };
|
|
|
|
|
|
Candidate first{whiteTexture, 0};
|
|
|
|
|
|
bool hasFirst = false;
|
|
|
|
|
|
Candidate firstNonWhite{whiteTexture, 0};
|
|
|
|
|
|
bool hasFirstNonWhite = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (uint32_t i = 0; i < comboCount; i++) {
|
|
|
|
|
|
uint32_t lookupPos = static_cast<uint32_t>(b.textureIndex) + i;
|
|
|
|
|
|
if (lookupPos >= gm.data.textureLookup.size()) break;
|
|
|
|
|
|
uint16_t texSlot = gm.data.textureLookup[lookupPos];
|
|
|
|
|
|
if (texSlot >= gm.textureIds.size()) continue;
|
|
|
|
|
|
|
|
|
|
|
|
GLuint texId = gm.textureIds[texSlot];
|
|
|
|
|
|
uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0;
|
2026-02-15 20:53:01 -08:00
|
|
|
|
// Apply texture slot overrides.
|
|
|
|
|
|
// For type-1 (skin) overrides, only apply to skin-group batches
|
|
|
|
|
|
// to prevent the skin composite from bleeding onto cloak/hair.
|
|
|
|
|
|
{
|
|
|
|
|
|
auto itO = inst.textureSlotOverrides.find(texSlot);
|
|
|
|
|
|
if (itO != inst.textureSlotOverrides.end() && itO->second != 0) {
|
|
|
|
|
|
if (texType == 1) {
|
|
|
|
|
|
// Only apply skin override to skin groups
|
|
|
|
|
|
uint16_t grp = b.submeshId / 100;
|
|
|
|
|
|
bool isSkinGroup = (grp == 0 || grp == 3 || grp == 4 || grp == 5 ||
|
|
|
|
|
|
grp == 8 || grp == 9 || grp == 13 || grp == 20);
|
|
|
|
|
|
if (isSkinGroup) texId = itO->second;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
texId = itO->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-12 14:55:27 -08:00
|
|
|
|
|
|
|
|
|
|
if (!hasFirst) {
|
|
|
|
|
|
first = {texId, texType};
|
|
|
|
|
|
hasFirst = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (texId == 0 || texId == whiteTexture) continue;
|
|
|
|
|
|
|
|
|
|
|
|
// Prefer the hair texture slot (type 6) whenever present in the combo.
|
|
|
|
|
|
// Humanoid scalp meshes can live in group 0, so group-based checks are insufficient.
|
|
|
|
|
|
if (texType == 6) {
|
|
|
|
|
|
return texId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasFirstNonWhite) {
|
|
|
|
|
|
firstNonWhite = {texId, texType};
|
|
|
|
|
|
hasFirstNonWhite = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (hasFirstNonWhite) return firstNonWhite.id;
|
|
|
|
|
|
if (hasFirst && first.id != 0) return first.id;
|
|
|
|
|
|
return whiteTexture;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// One-time debug dump of rendered batches per model
|
|
|
|
|
|
static std::unordered_set<uint32_t> dumpedModels;
|
|
|
|
|
|
if (dumpedModels.find(instance.modelId) == dumpedModels.end()) {
|
2026-02-05 23:33:28 -08:00
|
|
|
|
dumpedModels.insert(instance.modelId);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
int bIdx = 0;
|
|
|
|
|
|
int rendered = 0, skipped = 0;
|
|
|
|
|
|
for (const auto& b : gpuModel.data.batches) {
|
2026-02-11 18:25:04 -08:00
|
|
|
|
bool filtered = applyGeosetFilter &&
|
2026-02-06 01:02:35 -08:00
|
|
|
|
(b.submeshId / 100 != 0) &&
|
2026-02-05 23:27:59 -08:00
|
|
|
|
instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end();
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-13 19:40:54 -08:00
|
|
|
|
GLuint resolvedTex = resolveBatchTexture(instance, gpuModel, b);
|
2026-02-12 14:55:27 -08:00
|
|
|
|
std::string texInfo = "GL" + std::to_string(resolvedTex);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
if (filtered) skipped++; else rendered++;
|
|
|
|
|
|
LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId,
|
2026-02-11 22:27:02 -08:00
|
|
|
|
" level=", b.submeshLevel,
|
|
|
|
|
|
" idxStart=", b.indexStart, " idxCount=", b.indexCount,
|
|
|
|
|
|
" tex=", texInfo,
|
|
|
|
|
|
filtered ? " [SKIP]" : " [RENDER]");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
bIdx++;
|
|
|
|
|
|
}
|
2026-02-11 22:27:02 -08:00
|
|
|
|
LOG_DEBUG("Batch summary: ", rendered, " rendered, ", skipped, " skipped, ",
|
|
|
|
|
|
gpuModel.textureIds.size(), " textures loaded, ",
|
|
|
|
|
|
gpuModel.data.textureLookup.size(), " in lookup table");
|
2026-02-02 12:24:50 -08:00
|
|
|
|
for (size_t t = 0; t < gpuModel.data.textures.size(); t++) {
|
2026-02-12 14:55:27 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Draw batches (submeshes) with per-batch textures
|
|
|
|
|
|
// Geoset filtering: skip batches whose submeshId is not in activeGeosets.
|
|
|
|
|
|
// For character models, group 0 (body/scalp) is also filtered so that only
|
|
|
|
|
|
// the correct scalp mesh renders (not all overlapping variants).
|
|
|
|
|
|
for (const auto& batch : gpuModel.data.batches) {
|
2026-02-11 18:25:04 -08:00
|
|
|
|
if (applyGeosetFilter) {
|
2026-02-06 01:02:35 -08:00
|
|
|
|
if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) {
|
|
|
|
|
|
continue;
|
2026-02-05 23:44:45 -08:00
|
|
|
|
}
|
2026-02-05 23:48:06 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-12 14:55:27 -08:00
|
|
|
|
// Resolve texture for this batch (prefer hair textures for hair geosets).
|
2026-02-13 19:40:54 -08:00
|
|
|
|
GLuint texId = resolveBatchTexture(instance, gpuModel, batch);
|
2026-02-05 23:44:45 -08:00
|
|
|
|
|
2026-02-15 20:53:01 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// For body/equipment parts with white/fallback texture, use skin (type 1) texture.
|
|
|
|
|
|
// Groups that share the body skin atlas: 0=body, 3=gloves, 4=boots, 5=chest,
|
|
|
|
|
|
// 8=wristbands, 9=pelvis, 13=pants. Hair (group 1) and facial hair (group 2) do NOT.
|
2026-02-05 23:51:50 -08:00
|
|
|
|
if (texId == whiteTexture) {
|
|
|
|
|
|
uint16_t group = batch.submeshId / 100;
|
2026-02-15 20:53:01 -08:00
|
|
|
|
bool isSkinGroup = (group == 0 || group == 3 || group == 4 || group == 5 ||
|
|
|
|
|
|
group == 8 || group == 9 || group == 13);
|
|
|
|
|
|
if (isSkinGroup) {
|
|
|
|
|
|
// Check if this batch's texture slot is a hair type (don't override hair)
|
2026-02-06 15:18:50 -08:00
|
|
|
|
uint32_t texType = 0;
|
|
|
|
|
|
if (batch.textureIndex < gpuModel.data.textureLookup.size()) {
|
|
|
|
|
|
uint16_t lk = gpuModel.data.textureLookup[batch.textureIndex];
|
|
|
|
|
|
if (lk < gpuModel.data.textures.size()) {
|
|
|
|
|
|
texType = gpuModel.data.textures[lk].type;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Do NOT apply skin composite to hair (type 6) batches
|
|
|
|
|
|
if (texType != 6) {
|
|
|
|
|
|
for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) {
|
2026-02-13 19:40:54 -08:00
|
|
|
|
GLuint candidate = gpuModel.textureIds[ti];
|
|
|
|
|
|
auto itO = instance.textureSlotOverrides.find(static_cast<uint16_t>(ti));
|
|
|
|
|
|
if (itO != instance.textureSlotOverrides.end() && itO->second != 0) {
|
|
|
|
|
|
candidate = itO->second;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (candidate != whiteTexture && candidate != 0) {
|
2026-02-06 15:18:50 -08:00
|
|
|
|
// Only use type 1 (skin) textures as fallback
|
|
|
|
|
|
if (ti < gpuModel.data.textures.size() &&
|
|
|
|
|
|
(gpuModel.data.textures[ti].type == 1 || gpuModel.data.textures[ti].type == 11)) {
|
2026-02-13 19:40:54 -08:00
|
|
|
|
texId = candidate;
|
2026-02-06 15:18:50 -08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-05 23:51:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
glActiveTexture(GL_TEXTURE0);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, texId);
|
|
|
|
|
|
|
|
|
|
|
|
glDrawElements(GL_TRIANGLES,
|
|
|
|
|
|
batch.indexCount,
|
|
|
|
|
|
GL_UNSIGNED_SHORT,
|
|
|
|
|
|
(void*)(batch.indexStart * sizeof(uint16_t)));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Draw entire model with first texture
|
|
|
|
|
|
glActiveTexture(GL_TEXTURE0);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, !gpuModel.textureIds.empty() ? gpuModel.textureIds[0] : whiteTexture);
|
|
|
|
|
|
|
|
|
|
|
|
glDrawElements(GL_TRIANGLES,
|
|
|
|
|
|
static_cast<GLsizei>(gpuModel.data.indices.size()),
|
|
|
|
|
|
GL_UNSIGNED_SHORT,
|
|
|
|
|
|
0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glBindVertexArray(0);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
glDisable(GL_BLEND);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
glEnable(GL_CULL_FACE); // Restore culling for other renderers
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 16:41:40 -08:00
|
|
|
|
void CharacterRenderer::renderShadow(const glm::mat4& lightSpaceMatrix) {
|
|
|
|
|
|
if (instances.empty() || shadowCasterProgram == 0) {
|
2026-02-04 16:36:03 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 16:41:40 -08:00
|
|
|
|
glUseProgram(shadowCasterProgram);
|
|
|
|
|
|
|
|
|
|
|
|
GLint lightSpaceLoc = glGetUniformLocation(shadowCasterProgram, "uLightSpaceMatrix");
|
|
|
|
|
|
GLint modelLoc = glGetUniformLocation(shadowCasterProgram, "uModel");
|
|
|
|
|
|
GLint texLoc = glGetUniformLocation(shadowCasterProgram, "uTexture");
|
|
|
|
|
|
GLint alphaTestLoc = glGetUniformLocation(shadowCasterProgram, "uAlphaTest");
|
|
|
|
|
|
GLint bonesLoc = glGetUniformLocation(shadowCasterProgram, "uBones[0]");
|
|
|
|
|
|
if (lightSpaceLoc < 0 || modelLoc < 0) {
|
2026-02-04 16:36:03 -08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 16:41:40 -08:00
|
|
|
|
glUniformMatrix4fv(lightSpaceLoc, 1, GL_FALSE, &lightSpaceMatrix[0][0]);
|
|
|
|
|
|
glEnable(GL_CULL_FACE);
|
|
|
|
|
|
glCullFace(GL_FRONT);
|
|
|
|
|
|
|
2026-02-04 16:36:03 -08:00
|
|
|
|
if (texLoc >= 0) glUniform1i(texLoc, 0);
|
|
|
|
|
|
glActiveTexture(GL_TEXTURE0);
|
|
|
|
|
|
|
|
|
|
|
|
for (const auto& [_, instance] : instances) {
|
|
|
|
|
|
auto modelIt = models.find(instance.modelId);
|
|
|
|
|
|
if (modelIt == models.end()) continue;
|
|
|
|
|
|
const auto& gpuModel = modelIt->second;
|
|
|
|
|
|
|
|
|
|
|
|
glm::mat4 modelMat = instance.hasOverrideModelMatrix
|
|
|
|
|
|
? instance.overrideModelMatrix
|
|
|
|
|
|
: getModelMatrix(instance);
|
|
|
|
|
|
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, &modelMat[0][0]);
|
|
|
|
|
|
|
2026-02-04 16:41:40 -08:00
|
|
|
|
if (!instance.boneMatrices.empty() && bonesLoc >= 0) {
|
2026-02-04 16:36:03 -08:00
|
|
|
|
int numBones = std::min(static_cast<int>(instance.boneMatrices.size()), MAX_BONES);
|
2026-02-04 16:41:40 -08:00
|
|
|
|
glUniformMatrix4fv(bonesLoc, numBones, GL_FALSE, &instance.boneMatrices[0][0][0]);
|
2026-02-04 16:36:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glBindVertexArray(gpuModel.vao);
|
|
|
|
|
|
|
|
|
|
|
|
if (!gpuModel.data.batches.empty()) {
|
|
|
|
|
|
for (const auto& batch : gpuModel.data.batches) {
|
|
|
|
|
|
GLuint texId = whiteTexture;
|
|
|
|
|
|
if (batch.textureIndex < gpuModel.data.textureLookup.size()) {
|
|
|
|
|
|
uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex];
|
|
|
|
|
|
if (lookupIdx < gpuModel.textureIds.size()) {
|
|
|
|
|
|
texId = gpuModel.textureIds[lookupIdx];
|
2026-02-13 19:40:54 -08:00
|
|
|
|
auto itO = instance.textureSlotOverrides.find(lookupIdx);
|
|
|
|
|
|
if (itO != instance.textureSlotOverrides.end() && itO->second != 0) {
|
|
|
|
|
|
texId = itO->second;
|
|
|
|
|
|
}
|
2026-02-04 16:36:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-04 16:41:40 -08:00
|
|
|
|
bool alphaCutout = (texId != 0 && texId != whiteTexture);
|
|
|
|
|
|
if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, alphaCutout ? 1 : 0);
|
|
|
|
|
|
glBindTexture(GL_TEXTURE_2D, texId ? texId : whiteTexture);
|
2026-02-04 16:36:03 -08:00
|
|
|
|
|
|
|
|
|
|
glDrawElements(GL_TRIANGLES,
|
|
|
|
|
|
batch.indexCount,
|
|
|
|
|
|
GL_UNSIGNED_SHORT,
|
|
|
|
|
|
(void*)(batch.indexStart * sizeof(uint16_t)));
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (alphaTestLoc >= 0) glUniform1i(alphaTestLoc, 0);
|
2026-02-04 16:41:40 -08:00
|
|
|
|
glBindTexture(GL_TEXTURE_2D, whiteTexture);
|
2026-02-04 16:36:03 -08:00
|
|
|
|
glDrawElements(GL_TRIANGLES,
|
|
|
|
|
|
static_cast<GLsizei>(gpuModel.data.indices.size()),
|
|
|
|
|
|
GL_UNSIGNED_SHORT,
|
|
|
|
|
|
0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
glBindVertexArray(0);
|
2026-02-04 16:41:40 -08:00
|
|
|
|
glCullFace(GL_BACK);
|
2026-02-04 16:36:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
glm::mat4 CharacterRenderer::getModelMatrix(const CharacterInstance& instance) const {
|
|
|
|
|
|
glm::mat4 model = glm::mat4(1.0f);
|
|
|
|
|
|
|
|
|
|
|
|
// Apply transformations: T * R * S
|
|
|
|
|
|
model = glm::translate(model, instance.position);
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
// Apply rotation (euler angles, Z-up)
|
|
|
|
|
|
// Convention: yaw around Z, pitch around X, roll around Y.
|
|
|
|
|
|
model = glm::rotate(model, instance.rotation.z, glm::vec3(0.0f, 0.0f, 1.0f)); // Yaw
|
2026-02-02 12:24:50 -08:00
|
|
|
|
model = glm::rotate(model, instance.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); // Pitch
|
2026-02-06 18:34:45 -08:00
|
|
|
|
model = glm::rotate(model, instance.rotation.y, glm::vec3(0.0f, 1.0f, 0.0f)); // Roll
|
2026-02-02 12:24:50 -08:00
|
|
|
|
|
|
|
|
|
|
model = glm::scale(model, glm::vec3(instance.scale));
|
|
|
|
|
|
|
|
|
|
|
|
return model;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::setInstancePosition(uint32_t instanceId, const glm::vec3& position) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
|
it->second.position = position;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
|
it->second.rotation = rotation;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 13:47:03 -08:00
|
|
|
|
void CharacterRenderer::moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) return;
|
|
|
|
|
|
|
|
|
|
|
|
auto& inst = it->second;
|
2026-02-06 16:47:07 -08:00
|
|
|
|
|
|
|
|
|
|
// Don't move dead instances (corpses shouldn't slide around)
|
|
|
|
|
|
if (inst.isDead) return;
|
|
|
|
|
|
|
2026-02-18 03:56:12 -08:00
|
|
|
|
auto pickMoveAnim = [&](bool preferRun) -> uint32_t {
|
|
|
|
|
|
// Choose movement anim from estimated speed; fall back if missing.
|
|
|
|
|
|
if (preferRun) {
|
|
|
|
|
|
if (hasAnimation(instanceId, 5)) return 5; // Run
|
|
|
|
|
|
if (hasAnimation(instanceId, 4)) return 4; // Walk
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (hasAnimation(instanceId, 4)) return 4; // Walk
|
|
|
|
|
|
if (hasAnimation(instanceId, 5)) return 5; // Run
|
|
|
|
|
|
}
|
2026-02-18 03:53:53 -08:00
|
|
|
|
return 0;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
float planarDist = glm::length(glm::vec2(destination.x - inst.position.x,
|
|
|
|
|
|
destination.y - inst.position.y));
|
2026-02-18 03:56:12 -08:00
|
|
|
|
bool synthesizedDuration = false;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
if (durationSeconds <= 0.0f) {
|
2026-02-18 03:53:53 -08:00
|
|
|
|
if (planarDist < 0.01f) {
|
|
|
|
|
|
// Stop at current location.
|
|
|
|
|
|
inst.position = destination;
|
|
|
|
|
|
inst.isMoving = false;
|
|
|
|
|
|
if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) {
|
|
|
|
|
|
playAnimation(instanceId, 0, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
2026-02-18 03:53:53 -08:00
|
|
|
|
// Some cores send movement-only deltas without spline duration.
|
|
|
|
|
|
// Synthesize a tiny duration so movement anim/rotation still updates.
|
|
|
|
|
|
durationSeconds = std::clamp(planarDist / 7.0f, 0.05f, 0.20f);
|
2026-02-18 03:56:12 -08:00
|
|
|
|
synthesizedDuration = true;
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inst.moveStart = inst.position;
|
|
|
|
|
|
inst.moveEnd = destination;
|
|
|
|
|
|
inst.moveDuration = durationSeconds;
|
|
|
|
|
|
inst.moveElapsed = 0.0f;
|
|
|
|
|
|
inst.isMoving = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Face toward destination (yaw around Z axis since Z is up)
|
|
|
|
|
|
glm::vec3 dir = destination - inst.position;
|
|
|
|
|
|
if (glm::length(glm::vec2(dir.x, dir.y)) > 0.001f) {
|
|
|
|
|
|
float angle = std::atan2(dir.y, dir.x);
|
|
|
|
|
|
inst.rotation.z = angle;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 03:53:53 -08:00
|
|
|
|
// Play movement animation while moving.
|
2026-02-18 03:56:12 -08:00
|
|
|
|
// Prefer run only when speed is clearly above normal walk pace.
|
|
|
|
|
|
float moveSpeed = planarDist / std::max(durationSeconds, 0.001f);
|
|
|
|
|
|
bool preferRun = (!synthesizedDuration && moveSpeed >= 4.5f);
|
|
|
|
|
|
uint32_t moveAnim = pickMoveAnim(preferRun);
|
2026-02-18 03:53:53 -08:00
|
|
|
|
if (moveAnim != 0 && inst.currentAnimationId != moveAnim) {
|
|
|
|
|
|
playAnimation(instanceId, moveAnim, true);
|
2026-02-06 13:47:03 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const pipeline::M2Model* CharacterRenderer::getModelData(uint32_t modelId) const {
|
|
|
|
|
|
auto it = models.find(modelId);
|
|
|
|
|
|
if (it == models.end()) return nullptr;
|
|
|
|
|
|
return &it->second.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) return;
|
|
|
|
|
|
it->second.opacity = 0.0f;
|
|
|
|
|
|
it->second.fadeInTime = 0.0f;
|
|
|
|
|
|
it->second.fadeInDuration = durationSeconds;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
|
it->second.activeGeosets = geosets;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 01:02:35 -08:00
|
|
|
|
void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
|
it->second.groupTextureOverrides[geosetGroup] = textureId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 19:40:54 -08:00
|
|
|
|
void CharacterRenderer::setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, GLuint textureId) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
|
it->second.textureSlotOverrides[textureSlot] = textureId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
|
it->second.textureSlotOverrides.erase(textureSlot);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 14:26:08 -08:00
|
|
|
|
void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it != instances.end()) {
|
|
|
|
|
|
it->second.visible = visible;
|
|
|
|
|
|
|
|
|
|
|
|
// Also hide/show attached weapons (for first-person mode)
|
|
|
|
|
|
for (const auto& wa : it->second.weaponAttachments) {
|
|
|
|
|
|
auto weapIt = instances.find(wa.weaponInstanceId);
|
|
|
|
|
|
if (weapIt != instances.end()) {
|
|
|
|
|
|
weapIt->second.visible = visible;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void CharacterRenderer::removeInstance(uint32_t instanceId) {
|
2026-02-11 21:14:35 -08:00
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Remove child attachments first (helmets/weapons), otherwise they leak as
|
|
|
|
|
|
// orphan render instances when the parent creature despawns.
|
|
|
|
|
|
auto attachments = it->second.weaponAttachments;
|
|
|
|
|
|
for (const auto& wa : attachments) {
|
|
|
|
|
|
removeInstance(wa.weaponInstanceId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
instances.erase(it);
|
2026-02-02 12:24:50 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 14:55:32 -08:00
|
|
|
|
bool CharacterRenderer::getAnimationState(uint32_t instanceId, uint32_t& animationId,
|
|
|
|
|
|
float& animationTimeMs, float& animationDurationMs) const {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const CharacterInstance& instance = it->second;
|
|
|
|
|
|
auto modelIt = models.find(instance.modelId);
|
|
|
|
|
|
if (modelIt == models.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const auto& sequences = modelIt->second.data.sequences;
|
|
|
|
|
|
if (instance.currentSequenceIndex < 0 || instance.currentSequenceIndex >= static_cast<int>(sequences.size())) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
animationId = instance.currentAnimationId;
|
|
|
|
|
|
animationTimeMs = instance.animationTime;
|
|
|
|
|
|
animationDurationMs = static_cast<float>(sequences[instance.currentSequenceIndex].duration);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 19:29:11 -08:00
|
|
|
|
bool CharacterRenderer::hasAnimation(uint32_t instanceId, uint32_t animationId) const {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto modelIt = models.find(it->second.modelId);
|
|
|
|
|
|
if (modelIt == models.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const auto& sequences = modelIt->second.data.sequences;
|
|
|
|
|
|
for (const auto& seq : sequences) {
|
|
|
|
|
|
if (seq.id == animationId) {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-05 14:01:26 -08:00
|
|
|
|
bool CharacterRenderer::getAnimationSequences(uint32_t instanceId, std::vector<pipeline::M2Sequence>& out) const {
|
|
|
|
|
|
out.clear();
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto modelIt = models.find(it->second.modelId);
|
|
|
|
|
|
if (modelIt == models.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
out = modelIt->second.data.sequences;
|
|
|
|
|
|
return !out.empty();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-03 19:49:56 -08:00
|
|
|
|
bool CharacterRenderer::getInstanceModelName(uint32_t instanceId, std::string& modelName) const {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
auto modelIt = models.find(it->second.modelId);
|
|
|
|
|
|
if (modelIt == models.end()) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
modelName = modelIt->second.data.name;
|
|
|
|
|
|
return !modelName.empty();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmentId,
|
|
|
|
|
|
const pipeline::M2Model& weaponModel, uint32_t weaponModelId,
|
|
|
|
|
|
const std::string& texturePath) {
|
|
|
|
|
|
auto charIt = instances.find(charInstanceId);
|
|
|
|
|
|
if (charIt == instances.end()) {
|
|
|
|
|
|
core::Logger::getInstance().warning("attachWeapon: character instance ", charInstanceId, " not found");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
auto& charInstance = charIt->second;
|
|
|
|
|
|
auto charModelIt = models.find(charInstance.modelId);
|
|
|
|
|
|
if (charModelIt == models.end()) return false;
|
|
|
|
|
|
const auto& charModel = charModelIt->second.data;
|
|
|
|
|
|
|
|
|
|
|
|
// Find bone index for this attachment point
|
|
|
|
|
|
uint16_t boneIndex = 0;
|
|
|
|
|
|
glm::vec3 offset(0.0f);
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Try attachment lookup first
|
|
|
|
|
|
if (attachmentId < charModel.attachmentLookup.size()) {
|
|
|
|
|
|
uint16_t attIdx = charModel.attachmentLookup[attachmentId];
|
|
|
|
|
|
if (attIdx < charModel.attachments.size()) {
|
|
|
|
|
|
boneIndex = charModel.attachments[attIdx].bone;
|
|
|
|
|
|
offset = charModel.attachments[attIdx].position;
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fallback: scan attachments by id
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
for (const auto& att : charModel.attachments) {
|
|
|
|
|
|
if (att.id == attachmentId) {
|
|
|
|
|
|
boneIndex = att.bone;
|
|
|
|
|
|
offset = att.position;
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// Fallback: scan bones for keyBoneId 26 (right hand) / 27 (left hand)
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27;
|
|
|
|
|
|
for (size_t i = 0; i < charModel.bones.size(); i++) {
|
|
|
|
|
|
if (charModel.bones[i].keyBoneId == targetKeyBone) {
|
|
|
|
|
|
boneIndex = static_cast<uint16_t>(i);
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
// Validate bone index (bad attachment tables should not silently bind to origin)
|
|
|
|
|
|
if (found && boneIndex >= charModel.bones.size()) {
|
|
|
|
|
|
found = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27;
|
|
|
|
|
|
for (size_t i = 0; i < charModel.bones.size(); i++) {
|
|
|
|
|
|
if (charModel.bones[i].keyBoneId == targetKeyBone) {
|
|
|
|
|
|
boneIndex = static_cast<uint16_t>(i);
|
|
|
|
|
|
offset = glm::vec3(0.0f);
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
if (!found) {
|
|
|
|
|
|
core::Logger::getInstance().warning("attachWeapon: no bone found for attachment ", attachmentId);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Remove existing weapon at this attachment point
|
|
|
|
|
|
detachWeapon(charInstanceId, attachmentId);
|
|
|
|
|
|
|
|
|
|
|
|
// Load weapon model into renderer
|
|
|
|
|
|
if (models.find(weaponModelId) == models.end()) {
|
|
|
|
|
|
if (!loadModel(weaponModel, weaponModelId)) {
|
|
|
|
|
|
core::Logger::getInstance().warning("attachWeapon: failed to load weapon model ", weaponModelId);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply weapon texture if provided
|
|
|
|
|
|
if (!texturePath.empty()) {
|
|
|
|
|
|
GLuint texId = loadTexture(texturePath);
|
|
|
|
|
|
if (texId != whiteTexture) {
|
|
|
|
|
|
setModelTexture(weaponModelId, 0, texId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create weapon instance
|
|
|
|
|
|
uint32_t weaponInstanceId = createInstance(weaponModelId, glm::vec3(0.0f));
|
|
|
|
|
|
if (weaponInstanceId == 0) return false;
|
|
|
|
|
|
|
|
|
|
|
|
// Mark weapon instance as override-positioned
|
|
|
|
|
|
auto weapIt = instances.find(weaponInstanceId);
|
|
|
|
|
|
if (weapIt != instances.end()) {
|
|
|
|
|
|
weapIt->second.hasOverrideModelMatrix = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Store attachment on parent character instance
|
|
|
|
|
|
WeaponAttachment wa;
|
|
|
|
|
|
wa.weaponModelId = weaponModelId;
|
|
|
|
|
|
wa.weaponInstanceId = weaponInstanceId;
|
|
|
|
|
|
wa.attachmentId = attachmentId;
|
|
|
|
|
|
wa.boneIndex = boneIndex;
|
|
|
|
|
|
wa.offset = offset;
|
|
|
|
|
|
charInstance.weaponAttachments.push_back(wa);
|
|
|
|
|
|
|
|
|
|
|
|
core::Logger::getInstance().info("Attached weapon model ", weaponModelId,
|
|
|
|
|
|
" to instance ", charInstanceId, " at attachment ", attachmentId,
|
|
|
|
|
|
" (bone ", boneIndex, ", offset ", offset.x, ",", offset.y, ",", offset.z, ")");
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-06 18:34:45 -08:00
|
|
|
|
bool CharacterRenderer::getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const {
|
|
|
|
|
|
auto it = instances.find(instanceId);
|
|
|
|
|
|
if (it == instances.end()) return false;
|
|
|
|
|
|
auto mIt = models.find(it->second.modelId);
|
|
|
|
|
|
if (mIt == models.end()) return false;
|
|
|
|
|
|
|
|
|
|
|
|
const auto& inst = it->second;
|
|
|
|
|
|
const auto& model = mIt->second.data;
|
|
|
|
|
|
|
|
|
|
|
|
glm::vec3 localCenter = (model.boundMin + model.boundMax) * 0.5f;
|
|
|
|
|
|
float radius = model.boundRadius;
|
|
|
|
|
|
if (radius <= 0.001f) {
|
|
|
|
|
|
radius = glm::length(model.boundMax - model.boundMin) * 0.5f;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
float scale = std::max(0.001f, inst.scale);
|
|
|
|
|
|
outCenter = inst.position + localCenter * scale;
|
|
|
|
|
|
outRadius = std::max(0.5f, radius * scale);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) {
|
|
|
|
|
|
auto charIt = instances.find(charInstanceId);
|
|
|
|
|
|
if (charIt == instances.end()) return;
|
|
|
|
|
|
auto& attachments = charIt->second.weaponAttachments;
|
|
|
|
|
|
|
|
|
|
|
|
for (auto it = attachments.begin(); it != attachments.end(); ++it) {
|
|
|
|
|
|
if (it->attachmentId == attachmentId) {
|
|
|
|
|
|
removeInstance(it->weaponInstanceId);
|
|
|
|
|
|
attachments.erase(it);
|
|
|
|
|
|
core::Logger::getInstance().info("Detached weapon from instance ", charInstanceId,
|
|
|
|
|
|
" attachment ", attachmentId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
bool CharacterRenderer::getAttachmentTransform(uint32_t instanceId, uint32_t attachmentId, glm::mat4& outTransform) {
|
|
|
|
|
|
auto instIt = instances.find(instanceId);
|
|
|
|
|
|
if (instIt == instances.end()) return false;
|
|
|
|
|
|
const auto& instance = instIt->second;
|
|
|
|
|
|
|
|
|
|
|
|
auto modelIt = models.find(instance.modelId);
|
|
|
|
|
|
if (modelIt == models.end()) return false;
|
|
|
|
|
|
const auto& model = modelIt->second.data;
|
|
|
|
|
|
|
|
|
|
|
|
// Find attachment point
|
|
|
|
|
|
uint16_t boneIndex = 0;
|
|
|
|
|
|
glm::vec3 offset(0.0f);
|
|
|
|
|
|
bool found = false;
|
|
|
|
|
|
|
|
|
|
|
|
// Try attachment lookup first
|
|
|
|
|
|
if (attachmentId < model.attachmentLookup.size()) {
|
|
|
|
|
|
uint16_t attIdx = model.attachmentLookup[attachmentId];
|
|
|
|
|
|
if (attIdx < model.attachments.size()) {
|
|
|
|
|
|
boneIndex = model.attachments[attIdx].bone;
|
|
|
|
|
|
offset = model.attachments[attIdx].position;
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback: scan attachments by id
|
|
|
|
|
|
if (!found) {
|
|
|
|
|
|
for (const auto& att : model.attachments) {
|
|
|
|
|
|
if (att.id == attachmentId) {
|
|
|
|
|
|
boneIndex = att.bone;
|
|
|
|
|
|
offset = att.position;
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!found) return false;
|
|
|
|
|
|
|
2026-02-13 20:26:55 -08:00
|
|
|
|
// Validate bone index; invalid indices bind attachments to origin (looks like weapons at feet).
|
|
|
|
|
|
if (boneIndex >= model.bones.size()) {
|
|
|
|
|
|
// Fallback: key bones (26/27) for hand attachments.
|
|
|
|
|
|
int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27;
|
|
|
|
|
|
found = false;
|
|
|
|
|
|
for (size_t i = 0; i < model.bones.size(); i++) {
|
|
|
|
|
|
if (model.bones[i].keyBoneId == targetKeyBone) {
|
|
|
|
|
|
boneIndex = static_cast<uint16_t>(i);
|
|
|
|
|
|
offset = glm::vec3(0.0f);
|
|
|
|
|
|
found = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!found) return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-10 19:30:45 -08:00
|
|
|
|
// Get bone matrix
|
|
|
|
|
|
glm::mat4 boneMat(1.0f);
|
|
|
|
|
|
if (boneIndex < instance.boneMatrices.size()) {
|
|
|
|
|
|
boneMat = instance.boneMatrices[boneIndex];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Compute world transform: modelMatrix * boneMatrix * offsetTranslation
|
|
|
|
|
|
glm::mat4 modelMat = instance.hasOverrideModelMatrix
|
|
|
|
|
|
? instance.overrideModelMatrix
|
|
|
|
|
|
: getModelMatrix(instance);
|
|
|
|
|
|
|
|
|
|
|
|
outTransform = modelMat * boneMat * glm::translate(glm::mat4(1.0f), offset);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void CharacterRenderer::dumpAnimations(uint32_t instanceId) const {
|
|
|
|
|
|
auto instIt = instances.find(instanceId);
|
|
|
|
|
|
if (instIt == instances.end()) {
|
|
|
|
|
|
core::Logger::getInstance().info("dumpAnimations: instance ", instanceId, " not found");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const auto& instance = instIt->second;
|
|
|
|
|
|
|
|
|
|
|
|
auto modelIt = models.find(instance.modelId);
|
|
|
|
|
|
if (modelIt == models.end()) {
|
|
|
|
|
|
core::Logger::getInstance().info("dumpAnimations: model not found for instance ", instanceId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const auto& model = modelIt->second.data;
|
|
|
|
|
|
|
|
|
|
|
|
core::Logger::getInstance().info("=== Animation dump for ", model.name, " ===");
|
|
|
|
|
|
core::Logger::getInstance().info("Total animations: ", model.sequences.size());
|
|
|
|
|
|
|
|
|
|
|
|
for (size_t i = 0; i < model.sequences.size(); i++) {
|
|
|
|
|
|
const auto& seq = model.sequences[i];
|
|
|
|
|
|
core::Logger::getInstance().info(" [", i, "] animId=", seq.id,
|
|
|
|
|
|
" variation=", seq.variationIndex,
|
|
|
|
|
|
" duration=", seq.duration, "ms",
|
|
|
|
|
|
" speed=", seq.movingSpeed,
|
|
|
|
|
|
" flags=0x", std::hex, seq.flags, std::dec);
|
|
|
|
|
|
}
|
|
|
|
|
|
core::Logger::getInstance().info("=== End animation dump ===");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 12:24:50 -08:00
|
|
|
|
} // namespace rendering
|
|
|
|
|
|
} // namespace wowee
|