Fix character appearance, previews, mount seat, and online unequip

This commit is contained in:
Kelsi 2026-02-12 14:55:27 -08:00
parent 4a023e773b
commit 275914b4db
19 changed files with 743 additions and 113 deletions

View file

@ -150,11 +150,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
uint32_t targetRaceId = static_cast<uint32_t>(race);
uint32_t targetSexId = (gender == game::Gender::FEMALE) ? 1u : 0u;
std::string bodySkinPath;
std::string faceLowerPath;
std::string faceUpperPath;
std::string hairScalpPath;
std::vector<std::string> underwearPaths;
bodySkinPath_.clear();
baseLayers_.clear();
auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc");
if (charSectionsDbc) {
@ -177,7 +178,7 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
variationIndex == 0 && colorIndex == static_cast<uint32_t>(skin)) {
std::string tex1 = charSectionsDbc->getString(r, 4);
if (!tex1.empty()) {
bodySkinPath = tex1;
bodySkinPath_ = tex1;
foundSkin = true;
}
}
@ -217,8 +218,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
// Assign texture filenames on model before GPU upload
for (auto& tex : model.textures) {
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath.empty()) {
tex.filename = bodySkinPath;
if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) {
tex.filename = bodySkinPath_;
} else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) {
tex.filename = hairScalpPath;
}
@ -247,9 +248,9 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
}
// Composite body skin + face + underwear overlays
if (!bodySkinPath.empty()) {
if (!bodySkinPath_.empty()) {
std::vector<std::string> layers;
layers.push_back(bodySkinPath);
layers.push_back(bodySkinPath_);
// Face lower texture composited onto body at the face region
if (!faceLowerPath.empty()) {
layers.push_back(faceLowerPath);
@ -261,6 +262,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
layers.push_back(up);
}
// Cache for later equipment compositing.
// Keep baseLayers_ without the base skin (compositeWithRegions takes basePath separately).
if (!faceLowerPath.empty()) baseLayers_.push_back(faceLowerPath);
if (!faceUpperPath.empty()) baseLayers_.push_back(faceUpperPath);
for (const auto& up : underwearPaths) baseLayers_.push_back(up);
if (layers.size() > 1) {
GLuint compositeTex = charRenderer_->compositeTextures(layers);
if (compositeTex != 0) {
@ -319,6 +326,22 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
// Play idle animation (Stand = animation ID 0)
charRenderer_->playAnimation(instanceId_, 0, true);
// Cache core appearance for later equipment geosets.
race_ = race;
gender_ = gender;
useFemaleModel_ = useFemaleModel;
hairStyle_ = hairStyle;
facialHair_ = facialHair;
// Cache the type-1 texture slot index so applyEquipment can update it.
skinTextureSlotIndex_ = 0;
for (size_t ti = 0; ti < model.textures.size(); ti++) {
if (model.textures[ti].type == 1) {
skinTextureSlotIndex_ = static_cast<uint32_t>(ti);
break;
}
}
modelLoaded_ = true;
LOG_INFO("CharacterPreview: loaded ", m2Path,
" skin=", (int)skin, " face=", (int)face,
@ -327,6 +350,150 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
return true;
}
bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& equipment) {
if (!modelLoaded_ || instanceId_ == 0 || !charRenderer_ || !assetManager_ || !assetManager_->isInitialized()) {
return false;
}
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
if (!displayInfoDbc || !displayInfoDbc->isLoaded()) {
return false;
}
auto hasInvType = [&](std::initializer_list<uint8_t> types) -> bool {
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
for (uint8_t t : types) {
if (it.inventoryType == t) return true;
}
}
return false;
};
auto findDisplayId = [&](std::initializer_list<uint8_t> types) -> uint32_t {
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
for (uint8_t t : types) {
if (it.inventoryType == t) return it.displayModel; // ItemDisplayInfo ID (3.3.5a char enum)
}
}
return 0;
};
auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t {
if (displayInfoId == 0) return 0;
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
if (recIdx < 0) return 0;
return displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
};
// --- Geosets ---
std::unordered_set<uint16_t> geosets;
for (uint16_t i = 0; i <= 18; i++) geosets.insert(i);
geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style
geosets.insert(static_cast<uint16_t>(200 + facialHair_ + 1)); // Facial hair
geosets.insert(701); // Ears
// Default naked geosets
uint16_t geosetGloves = 301;
uint16_t geosetBoots = 401;
uint16_t geosetChest = 501;
uint16_t geosetPants = 1301;
// Chest/Shirt/Robe
{
uint32_t did = findDisplayId({4, 5, 20});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetChest = static_cast<uint16_t>(501 + gg);
// Robe kilt legs
uint32_t gg3 = getGeosetGroup(did, 2);
if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3);
}
// Legs
{
uint32_t did = findDisplayId({7});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetPants = static_cast<uint16_t>(1301 + gg);
}
// Feet
{
uint32_t did = findDisplayId({8});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetBoots = static_cast<uint16_t>(401 + gg);
}
// Hands
{
uint32_t did = findDisplayId({10});
uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetGloves = static_cast<uint16_t>(301 + gg);
}
geosets.insert(geosetGloves);
geosets.insert(geosetBoots);
geosets.insert(geosetChest);
geosets.insert(geosetPants);
geosets.insert(hasInvType({16}) ? 1502 : 1501); // Cloak mesh toggle (visual may still be limited)
if (hasInvType({19})) geosets.insert(1201); // Tabard mesh toggle
// Hide hair under helmets (helmets are separate models; this still avoids hair clipping)
if (hasInvType({1})) {
geosets.erase(static_cast<uint16_t>(100 + hairStyle_ + 1));
geosets.insert(1); // Bald scalp cap
geosets.insert(101); // Default group-1 connector
}
charRenderer_->setActiveGeosets(instanceId_, geosets);
// --- Textures (equipment overlays onto body skin) ---
if (bodySkinPath_.empty()) return true; // geosets applied, but can't composite
static const char* componentDirs[] = {
"ArmUpperTexture", "ArmLowerTexture", "HandTexture",
"TorsoUpperTexture", "TorsoLowerTexture",
"LegUpperTexture", "LegLowerTexture", "FootTexture",
};
std::vector<std::pair<int, std::string>> regionLayers;
regionLayers.reserve(32);
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
int32_t recIdx = displayInfoDbc->findRecordById(it.displayModel);
if (recIdx < 0) continue;
for (int region = 0; region < 8; region++) {
uint32_t fieldIdx = 15 + region; // texture_1..texture_8
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), fieldIdx);
if (texName.empty()) continue;
std::string base = "Item\\TextureComponents\\" +
std::string(componentDirs[region]) + "\\" + texName;
std::string genderSuffix = (gender_ == game::Gender::FEMALE) ? "_F.blp" : "_M.blp";
std::string genderPath = base + genderSuffix;
std::string unisexPath = base + "_U.blp";
std::string fullPath;
if (assetManager_->fileExists(genderPath)) {
fullPath = genderPath;
} else if (assetManager_->fileExists(unisexPath)) {
fullPath = unisexPath;
} else {
fullPath = base + ".blp";
}
regionLayers.emplace_back(region, fullPath);
}
}
if (!regionLayers.empty()) {
GLuint newTex = charRenderer_->compositeWithRegions(bodySkinPath_, baseLayers_, regionLayers);
if (newTex != 0) {
charRenderer_->setModelTexture(PREVIEW_MODEL_ID, skinTextureSlotIndex_, newTex);
}
}
return true;
}
void CharacterPreview::update(float deltaTime) {
if (charRenderer_ && modelLoaded_) {
charRenderer_->update(deltaTime);

View file

@ -1257,16 +1257,16 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
// Bind VAO and draw
glBindVertexArray(gpuModel.vao);
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;
}
}
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;
}
}
if (!hasRenderableGeoset) {
static std::unordered_set<uint32_t> loggedGeosetFallback;
if (loggedGeosetFallback.insert(instance.id).second) {
@ -1274,13 +1274,64 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
instance.id, " (model ", instance.modelId,
"); rendering all batches as fallback");
}
applyGeosetFilter = false;
}
}
applyGeosetFilter = false;
}
}
// One-time debug dump of rendered batches per model
static std::unordered_set<uint32_t> dumpedModels;
if (dumpedModels.find(instance.modelId) == dumpedModels.end()) {
auto resolveBatchTexture = [&](const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint {
// 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;
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()) {
dumpedModels.insert(instance.modelId);
int bIdx = 0;
int rendered = 0, skipped = 0;
@ -1289,24 +1340,11 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
(b.submeshId / 100 != 0) &&
instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end();
GLuint resolvedTex = whiteTexture;
std::string texInfo = "white(fallback)";
if (b.textureIndex != 0xFFFF && b.textureIndex < gpuModel.data.textureLookup.size()) {
uint16_t lk = gpuModel.data.textureLookup[b.textureIndex];
if (lk < gpuModel.textureIds.size()) {
resolvedTex = gpuModel.textureIds[lk];
texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->tex[" + std::to_string(lk) + "]=GL" + std::to_string(resolvedTex);
} else {
texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->OOB(" + std::to_string(lk) + ")";
}
} else if (b.textureIndex == 0xFFFF) {
texInfo = "texIdx=FFFF";
} else {
texInfo = "texIdx=" + std::to_string(b.textureIndex) + " OOB(lookupSz=" + std::to_string(gpuModel.data.textureLookup.size()) + ")";
}
GLuint resolvedTex = resolveBatchTexture(gpuModel, b);
std::string texInfo = "GL" + std::to_string(resolvedTex);
if (filtered) skipped++; else rendered++;
LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId,
if (filtered) skipped++; else rendered++;
LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId,
" level=", b.submeshLevel,
" idxStart=", b.indexStart, " idxCount=", b.indexCount,
" tex=", texInfo,
@ -1317,28 +1355,22 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
gpuModel.textureIds.size(), " textures loaded, ",
gpuModel.data.textureLookup.size(), " in lookup table");
for (size_t t = 0; t < gpuModel.data.textures.size(); t++) {
}
}
}
}
// 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) {
// 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) {
if (applyGeosetFilter) {
if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) {
continue;
}
}
// Resolve texture for this batch
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];
}
}
// Resolve texture for this batch (prefer hair textures for hair geosets).
GLuint texId = resolveBatchTexture(gpuModel, batch);
// For body parts with white/fallback texture, use skin (type 1) texture
// This handles humanoid models where some body parts use different texture slots

View file

@ -1231,7 +1231,12 @@ void Renderer::updateCharacterAnimation() {
// Keep seat offset minimal; large offsets amplify visible bobble.
glm::vec3 seatOffset = glm::vec3(0.0f, 0.0f, taxiFlight_ ? 0.04f : 0.08f);
glm::vec3 targetRiderPos = mountSeatPos + seatOffset;
if (!mountSeatSmoothingInit_) {
// When moving, smoothing the seat position produces visible lag that looks like
// the rider sliding toward the rump. Anchor rigidly while moving.
if (moving) {
mountSeatSmoothingInit_ = false;
smoothedMountSeatPos_ = targetRiderPos;
} else if (!mountSeatSmoothingInit_) {
smoothedMountSeatPos_ = targetRiderPos;
mountSeatSmoothingInit_ = true;
} else {