mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-17 17:43:52 +00:00
Add 3D character model preview to character creation screen
Render an animated M2 character model in the creation screen using a dedicated CharacterRenderer with an offscreen FBO. Preview updates on race/gender/appearance changes, supports mouse-drag rotation, and composites skin, face, hair scalp, and underwear textures from CharSections.dbc. Extracts getPlayerModelPath() to a shared free function in game/character.
This commit is contained in:
parent
48d2808872
commit
060f2bbb9f
6 changed files with 631 additions and 22 deletions
|
|
@ -133,5 +133,10 @@ const char* getClassName(Class characterClass);
|
||||||
*/
|
*/
|
||||||
const char* getGenderName(Gender gender);
|
const char* getGenderName(Gender gender);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get M2 model path for a given race and gender
|
||||||
|
*/
|
||||||
|
std::string getPlayerModelPath(Race race, Gender gender);
|
||||||
|
|
||||||
} // namespace game
|
} // namespace game
|
||||||
} // namespace wowee
|
} // namespace wowee
|
||||||
|
|
|
||||||
57
include/rendering/character_preview.hpp
Normal file
57
include/rendering/character_preview.hpp
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "game/character.hpp"
|
||||||
|
#include <GL/glew.h>
|
||||||
|
#include <memory>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace wowee {
|
||||||
|
namespace pipeline { class AssetManager; }
|
||||||
|
namespace rendering {
|
||||||
|
|
||||||
|
class CharacterRenderer;
|
||||||
|
class Camera;
|
||||||
|
|
||||||
|
class CharacterPreview {
|
||||||
|
public:
|
||||||
|
CharacterPreview();
|
||||||
|
~CharacterPreview();
|
||||||
|
|
||||||
|
bool initialize(pipeline::AssetManager* am);
|
||||||
|
void shutdown();
|
||||||
|
|
||||||
|
bool loadCharacter(game::Race race, game::Gender gender,
|
||||||
|
uint8_t skin, uint8_t face,
|
||||||
|
uint8_t hairStyle, uint8_t hairColor,
|
||||||
|
uint8_t facialHair);
|
||||||
|
|
||||||
|
void update(float deltaTime);
|
||||||
|
void render();
|
||||||
|
void rotate(float yawDelta);
|
||||||
|
|
||||||
|
GLuint getTextureId() const { return colorTexture_; }
|
||||||
|
int getWidth() const { return fboWidth_; }
|
||||||
|
int getHeight() const { return fboHeight_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void createFBO();
|
||||||
|
void destroyFBO();
|
||||||
|
|
||||||
|
pipeline::AssetManager* assetManager_ = nullptr;
|
||||||
|
std::unique_ptr<CharacterRenderer> charRenderer_;
|
||||||
|
std::unique_ptr<Camera> camera_;
|
||||||
|
|
||||||
|
GLuint fbo_ = 0;
|
||||||
|
GLuint colorTexture_ = 0;
|
||||||
|
GLuint depthRenderbuffer_ = 0;
|
||||||
|
static constexpr int fboWidth_ = 400;
|
||||||
|
static constexpr int fboHeight_ = 500;
|
||||||
|
|
||||||
|
static constexpr uint32_t PREVIEW_MODEL_ID = 9999;
|
||||||
|
uint32_t instanceId_ = 0;
|
||||||
|
bool modelLoaded_ = false;
|
||||||
|
float modelYaw_ = 180.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace rendering
|
||||||
|
} // namespace wowee
|
||||||
|
|
@ -6,21 +6,27 @@
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
namespace game { class GameHandler; }
|
namespace game { class GameHandler; }
|
||||||
|
namespace pipeline { class AssetManager; }
|
||||||
|
namespace rendering { class CharacterPreview; }
|
||||||
|
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
class CharacterCreateScreen {
|
class CharacterCreateScreen {
|
||||||
public:
|
public:
|
||||||
CharacterCreateScreen();
|
CharacterCreateScreen();
|
||||||
|
~CharacterCreateScreen();
|
||||||
|
|
||||||
void render(game::GameHandler& gameHandler);
|
void render(game::GameHandler& gameHandler);
|
||||||
|
void update(float deltaTime);
|
||||||
void setOnCreate(std::function<void(const game::CharCreateData&)> cb) { onCreate = std::move(cb); }
|
void setOnCreate(std::function<void(const game::CharCreateData&)> cb) { onCreate = std::move(cb); }
|
||||||
void setOnCancel(std::function<void()> cb) { onCancel = std::move(cb); }
|
void setOnCancel(std::function<void()> cb) { onCancel = std::move(cb); }
|
||||||
void setStatus(const std::string& msg, bool isError = false);
|
void setStatus(const std::string& msg, bool isError = false);
|
||||||
void reset();
|
void reset();
|
||||||
|
void initializePreview(pipeline::AssetManager* am);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
char nameBuffer[13] = {}; // WoW max name = 12 chars + null
|
char nameBuffer[13] = {}; // WoW max name = 12 chars + null
|
||||||
|
|
@ -36,6 +42,20 @@ private:
|
||||||
|
|
||||||
std::function<void(const game::CharCreateData&)> onCreate;
|
std::function<void(const game::CharCreateData&)> onCreate;
|
||||||
std::function<void()> onCancel;
|
std::function<void()> onCancel;
|
||||||
|
|
||||||
|
// 3D model preview
|
||||||
|
std::unique_ptr<rendering::CharacterPreview> preview_;
|
||||||
|
int prevRaceIndex_ = -1;
|
||||||
|
int prevGenderIndex_ = -1;
|
||||||
|
int prevSkin_ = -1;
|
||||||
|
int prevFace_ = -1;
|
||||||
|
int prevHairStyle_ = -1;
|
||||||
|
int prevHairColor_ = -1;
|
||||||
|
int prevFacialHair_ = -1;
|
||||||
|
bool draggingPreview_ = false;
|
||||||
|
float dragStartX_ = 0.0f;
|
||||||
|
|
||||||
|
void updatePreviewIfNeeded();
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|
|
||||||
|
|
@ -92,5 +92,52 @@ const char* getGenderName(Gender gender) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string getPlayerModelPath(Race race, Gender gender) {
|
||||||
|
switch (race) {
|
||||||
|
case Race::HUMAN:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Human\\Female\\HumanFemale.m2"
|
||||||
|
: "Character\\Human\\Male\\HumanMale.m2";
|
||||||
|
case Race::ORC:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Orc\\Female\\OrcFemale.m2"
|
||||||
|
: "Character\\Orc\\Male\\OrcMale.m2";
|
||||||
|
case Race::DWARF:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Dwarf\\Female\\DwarfFemale.m2"
|
||||||
|
: "Character\\Dwarf\\Male\\DwarfMale.m2";
|
||||||
|
case Race::NIGHT_ELF:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\NightElf\\Female\\NightElfFemale.m2"
|
||||||
|
: "Character\\NightElf\\Male\\NightElfMale.m2";
|
||||||
|
case Race::UNDEAD:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Scourge\\Female\\ScourgeFemale.m2"
|
||||||
|
: "Character\\Scourge\\Male\\ScourgeMale.m2";
|
||||||
|
case Race::TAUREN:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Tauren\\Female\\TaurenFemale.m2"
|
||||||
|
: "Character\\Tauren\\Male\\TaurenMale.m2";
|
||||||
|
case Race::GNOME:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Gnome\\Female\\GnomeFemale.m2"
|
||||||
|
: "Character\\Gnome\\Male\\GnomeMale.m2";
|
||||||
|
case Race::TROLL:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Troll\\Female\\TrollFemale.m2"
|
||||||
|
: "Character\\Troll\\Male\\TrollMale.m2";
|
||||||
|
case Race::BLOOD_ELF:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\BloodElf\\Female\\BloodElfFemale.m2"
|
||||||
|
: "Character\\BloodElf\\Male\\BloodElfMale.m2";
|
||||||
|
case Race::DRAENEI:
|
||||||
|
return gender == Gender::FEMALE
|
||||||
|
? "Character\\Draenei\\Female\\DraeneiFemale.m2"
|
||||||
|
: "Character\\Draenei\\Male\\DraeneiMale.m2";
|
||||||
|
default:
|
||||||
|
return "Character\\Human\\Male\\HumanMale.m2";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace game
|
} // namespace game
|
||||||
} // namespace wowee
|
} // namespace wowee
|
||||||
|
|
|
||||||
369
src/rendering/character_preview.cpp
Normal file
369
src/rendering/character_preview.cpp
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
#include "rendering/character_preview.hpp"
|
||||||
|
#include "rendering/character_renderer.hpp"
|
||||||
|
#include "rendering/camera.hpp"
|
||||||
|
#include "pipeline/asset_manager.hpp"
|
||||||
|
#include "pipeline/m2_loader.hpp"
|
||||||
|
#include "pipeline/dbc_loader.hpp"
|
||||||
|
#include "core/logger.hpp"
|
||||||
|
#include <GL/glew.h>
|
||||||
|
#include <glm/gtc/matrix_transform.hpp>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
namespace wowee {
|
||||||
|
namespace rendering {
|
||||||
|
|
||||||
|
CharacterPreview::CharacterPreview() = default;
|
||||||
|
|
||||||
|
CharacterPreview::~CharacterPreview() {
|
||||||
|
shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPreview::initialize(pipeline::AssetManager* am) {
|
||||||
|
assetManager_ = am;
|
||||||
|
|
||||||
|
charRenderer_ = std::make_unique<CharacterRenderer>();
|
||||||
|
if (!charRenderer_->initialize()) {
|
||||||
|
LOG_ERROR("CharacterPreview: failed to initialize CharacterRenderer");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
charRenderer_->setAssetManager(am);
|
||||||
|
|
||||||
|
// Disable fog and shadows for the preview
|
||||||
|
charRenderer_->setFog(glm::vec3(0.05f, 0.05f, 0.1f), 9999.0f, 10000.0f);
|
||||||
|
charRenderer_->clearShadowMap();
|
||||||
|
|
||||||
|
camera_ = std::make_unique<Camera>();
|
||||||
|
// Portrait-style camera: WoW Z-up coordinate system
|
||||||
|
// Model at origin, camera positioned along +Y looking toward -Y
|
||||||
|
camera_->setFov(30.0f);
|
||||||
|
camera_->setAspectRatio(static_cast<float>(fboWidth_) / static_cast<float>(fboHeight_));
|
||||||
|
// Pull camera back far enough to see full body + head with margin
|
||||||
|
// Human ~2 units tall, Tauren ~2.5. At distance 4.5 with FOV 30:
|
||||||
|
// vertical visible = 2 * 4.5 * tan(15°) ≈ 2.41 units
|
||||||
|
camera_->setPosition(glm::vec3(0.0f, 4.5f, 0.9f));
|
||||||
|
camera_->setRotation(270.0f, 0.0f);
|
||||||
|
|
||||||
|
createFBO();
|
||||||
|
|
||||||
|
LOG_INFO("CharacterPreview initialized (", fboWidth_, "x", fboHeight_, ")");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterPreview::shutdown() {
|
||||||
|
destroyFBO();
|
||||||
|
if (charRenderer_) {
|
||||||
|
charRenderer_->shutdown();
|
||||||
|
charRenderer_.reset();
|
||||||
|
}
|
||||||
|
camera_.reset();
|
||||||
|
modelLoaded_ = false;
|
||||||
|
instanceId_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterPreview::createFBO() {
|
||||||
|
// Create color texture
|
||||||
|
glGenTextures(1, &colorTexture_);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, colorTexture_);
|
||||||
|
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, fboWidth_, fboHeight_, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||||
|
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||||
|
glBindTexture(GL_TEXTURE_2D, 0);
|
||||||
|
|
||||||
|
// Create depth renderbuffer
|
||||||
|
glGenRenderbuffers(1, &depthRenderbuffer_);
|
||||||
|
glBindRenderbuffer(GL_RENDERBUFFER, depthRenderbuffer_);
|
||||||
|
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, fboWidth_, fboHeight_);
|
||||||
|
glBindRenderbuffer(GL_RENDERBUFFER, 0);
|
||||||
|
|
||||||
|
// Create FBO
|
||||||
|
glGenFramebuffers(1, &fbo_);
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
|
||||||
|
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexture_, 0);
|
||||||
|
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depthRenderbuffer_);
|
||||||
|
|
||||||
|
GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
|
||||||
|
if (status != GL_FRAMEBUFFER_COMPLETE) {
|
||||||
|
LOG_ERROR("CharacterPreview: FBO incomplete, status=", status);
|
||||||
|
}
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterPreview::destroyFBO() {
|
||||||
|
if (fbo_) { glDeleteFramebuffers(1, &fbo_); fbo_ = 0; }
|
||||||
|
if (colorTexture_) { glDeleteTextures(1, &colorTexture_); colorTexture_ = 0; }
|
||||||
|
if (depthRenderbuffer_) { glDeleteRenderbuffers(1, &depthRenderbuffer_); depthRenderbuffer_ = 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
|
||||||
|
uint8_t skin, uint8_t face,
|
||||||
|
uint8_t hairStyle, uint8_t hairColor,
|
||||||
|
uint8_t facialHair) {
|
||||||
|
if (!charRenderer_ || !assetManager_ || !assetManager_->isInitialized()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove existing instance
|
||||||
|
if (instanceId_ > 0) {
|
||||||
|
charRenderer_->removeInstance(instanceId_);
|
||||||
|
instanceId_ = 0;
|
||||||
|
modelLoaded_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string m2Path = game::getPlayerModelPath(race, gender);
|
||||||
|
std::string modelDir;
|
||||||
|
std::string baseName;
|
||||||
|
{
|
||||||
|
size_t slash = m2Path.rfind('\\');
|
||||||
|
if (slash != std::string::npos) {
|
||||||
|
modelDir = m2Path.substr(0, slash + 1);
|
||||||
|
baseName = m2Path.substr(slash + 1);
|
||||||
|
} else {
|
||||||
|
baseName = m2Path;
|
||||||
|
}
|
||||||
|
size_t dot = baseName.rfind('.');
|
||||||
|
if (dot != std::string::npos) {
|
||||||
|
baseName = baseName.substr(0, dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto m2Data = assetManager_->readFile(m2Path);
|
||||||
|
if (m2Data.empty()) {
|
||||||
|
LOG_WARNING("CharacterPreview: failed to read M2: ", m2Path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto model = pipeline::M2Loader::load(m2Data);
|
||||||
|
|
||||||
|
// Load skin file
|
||||||
|
std::string skinPath = modelDir + baseName + "00.skin";
|
||||||
|
auto skinData = assetManager_->readFile(skinPath);
|
||||||
|
if (!skinData.empty()) {
|
||||||
|
pipeline::M2Loader::loadSkin(skinData, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!model.isValid()) {
|
||||||
|
LOG_WARNING("CharacterPreview: invalid model: ", m2Path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up CharSections.dbc for all appearance textures
|
||||||
|
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;
|
||||||
|
|
||||||
|
auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc");
|
||||||
|
if (charSectionsDbc) {
|
||||||
|
bool foundSkin = false;
|
||||||
|
bool foundFace = false;
|
||||||
|
bool foundHair = false;
|
||||||
|
bool foundUnderwear = false;
|
||||||
|
|
||||||
|
for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
|
||||||
|
uint32_t raceId = charSectionsDbc->getUInt32(r, 1);
|
||||||
|
uint32_t sexId = charSectionsDbc->getUInt32(r, 2);
|
||||||
|
uint32_t baseSection = charSectionsDbc->getUInt32(r, 3);
|
||||||
|
uint32_t variationIndex = charSectionsDbc->getUInt32(r, 8);
|
||||||
|
uint32_t colorIndex = charSectionsDbc->getUInt32(r, 9);
|
||||||
|
|
||||||
|
if (raceId != targetRaceId || sexId != targetSexId) continue;
|
||||||
|
|
||||||
|
// Section 0: Body skin (variation = skin color, colorIndex = 0)
|
||||||
|
if (baseSection == 0 && !foundSkin &&
|
||||||
|
variationIndex == static_cast<uint32_t>(skin) && colorIndex == 0) {
|
||||||
|
std::string tex1 = charSectionsDbc->getString(r, 4);
|
||||||
|
if (!tex1.empty()) {
|
||||||
|
bodySkinPath = tex1;
|
||||||
|
foundSkin = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Section 1: Face (variation = face index, colorIndex = skin color)
|
||||||
|
else if (baseSection == 1 && !foundFace &&
|
||||||
|
variationIndex == static_cast<uint32_t>(face) &&
|
||||||
|
colorIndex == static_cast<uint32_t>(skin)) {
|
||||||
|
std::string tex1 = charSectionsDbc->getString(r, 4);
|
||||||
|
std::string tex2 = charSectionsDbc->getString(r, 5);
|
||||||
|
if (!tex1.empty()) faceLowerPath = tex1;
|
||||||
|
if (!tex2.empty()) faceUpperPath = tex2;
|
||||||
|
foundFace = true;
|
||||||
|
}
|
||||||
|
// Section 3: Hair (variation = hair style, colorIndex = hair color)
|
||||||
|
else if (baseSection == 3 && !foundHair &&
|
||||||
|
variationIndex == static_cast<uint32_t>(hairStyle) &&
|
||||||
|
colorIndex == static_cast<uint32_t>(hairColor)) {
|
||||||
|
std::string tex1 = charSectionsDbc->getString(r, 4);
|
||||||
|
if (!tex1.empty()) {
|
||||||
|
hairScalpPath = tex1;
|
||||||
|
foundHair = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Section 4: Underwear (variation = skin color, colorIndex = 0)
|
||||||
|
else if (baseSection == 4 && !foundUnderwear &&
|
||||||
|
variationIndex == static_cast<uint32_t>(skin) && colorIndex == 0) {
|
||||||
|
for (int f = 4; f <= 6; f++) {
|
||||||
|
std::string tex = charSectionsDbc->getString(r, f);
|
||||||
|
if (!tex.empty()) {
|
||||||
|
underwearPaths.push_back(tex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundUnderwear = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
} else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) {
|
||||||
|
tex.filename = hairScalpPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load external .anim files
|
||||||
|
for (uint32_t si = 0; si < model.sequences.size(); si++) {
|
||||||
|
if (!(model.sequences[si].flags & 0x20)) {
|
||||||
|
char animFileName[256];
|
||||||
|
snprintf(animFileName, sizeof(animFileName),
|
||||||
|
"%s%s%04u-%02u.anim",
|
||||||
|
modelDir.c_str(),
|
||||||
|
baseName.c_str(),
|
||||||
|
model.sequences[si].id,
|
||||||
|
model.sequences[si].variationIndex);
|
||||||
|
auto animFileData = assetManager_->readFile(animFileName);
|
||||||
|
if (!animFileData.empty()) {
|
||||||
|
pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
charRenderer_->loadModel(model, PREVIEW_MODEL_ID);
|
||||||
|
|
||||||
|
// Composite body skin + face + underwear overlays
|
||||||
|
if (!bodySkinPath.empty()) {
|
||||||
|
std::vector<std::string> layers;
|
||||||
|
layers.push_back(bodySkinPath);
|
||||||
|
// Face lower texture composited onto body at the face region
|
||||||
|
if (!faceLowerPath.empty()) {
|
||||||
|
layers.push_back(faceLowerPath);
|
||||||
|
}
|
||||||
|
if (!faceUpperPath.empty()) {
|
||||||
|
layers.push_back(faceUpperPath);
|
||||||
|
}
|
||||||
|
for (const auto& up : underwearPaths) {
|
||||||
|
layers.push_back(up);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layers.size() > 1) {
|
||||||
|
GLuint compositeTex = charRenderer_->compositeTextures(layers);
|
||||||
|
if (compositeTex != 0) {
|
||||||
|
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||||
|
if (model.textures[ti].type == 1) {
|
||||||
|
charRenderer_->setModelTexture(PREVIEW_MODEL_ID, static_cast<uint32_t>(ti), compositeTex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hair scalp texture was found, ensure it's loaded for type-6 slot
|
||||||
|
if (!hairScalpPath.empty()) {
|
||||||
|
GLuint hairTex = charRenderer_->loadTexture(hairScalpPath);
|
||||||
|
if (hairTex != 0) {
|
||||||
|
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||||
|
if (model.textures[ti].type == 6) {
|
||||||
|
charRenderer_->setModelTexture(PREVIEW_MODEL_ID, static_cast<uint32_t>(ti), hairTex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create instance at origin with current yaw
|
||||||
|
instanceId_ = charRenderer_->createInstance(PREVIEW_MODEL_ID,
|
||||||
|
glm::vec3(0.0f, 0.0f, 0.0f),
|
||||||
|
glm::vec3(0.0f, 0.0f, modelYaw_),
|
||||||
|
1.0f);
|
||||||
|
|
||||||
|
if (instanceId_ == 0) {
|
||||||
|
LOG_WARNING("CharacterPreview: failed to create instance");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default geosets (naked character)
|
||||||
|
std::unordered_set<uint16_t> activeGeosets;
|
||||||
|
// Body parts (group 0: IDs 0-18)
|
||||||
|
for (uint16_t i = 0; i <= 18; i++) {
|
||||||
|
activeGeosets.insert(i);
|
||||||
|
}
|
||||||
|
// Hair style geoset: group 1 = 100 + variation + 1
|
||||||
|
activeGeosets.insert(static_cast<uint16_t>(100 + hairStyle + 1));
|
||||||
|
// Facial hair geoset: group 2 = 200 + variation + 1
|
||||||
|
activeGeosets.insert(static_cast<uint16_t>(200 + facialHair + 1));
|
||||||
|
activeGeosets.insert(301); // Gloves: bare hands
|
||||||
|
activeGeosets.insert(401); // Boots: bare feet
|
||||||
|
activeGeosets.insert(501); // Chest: bare
|
||||||
|
activeGeosets.insert(701); // Ears: default
|
||||||
|
activeGeosets.insert(1301); // Trousers: bare legs
|
||||||
|
activeGeosets.insert(1501); // Back body (cloak=none)
|
||||||
|
charRenderer_->setActiveGeosets(instanceId_, activeGeosets);
|
||||||
|
|
||||||
|
// Play idle animation (Stand = animation ID 0)
|
||||||
|
charRenderer_->playAnimation(instanceId_, 0, true);
|
||||||
|
|
||||||
|
modelLoaded_ = true;
|
||||||
|
LOG_INFO("CharacterPreview: loaded ", m2Path,
|
||||||
|
" skin=", (int)skin, " face=", (int)face,
|
||||||
|
" hair=", (int)hairStyle, " hairColor=", (int)hairColor,
|
||||||
|
" facial=", (int)facialHair);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterPreview::update(float deltaTime) {
|
||||||
|
if (charRenderer_ && modelLoaded_) {
|
||||||
|
charRenderer_->update(deltaTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterPreview::render() {
|
||||||
|
if (!fbo_ || !charRenderer_ || !camera_ || !modelLoaded_) return;
|
||||||
|
|
||||||
|
// Save current viewport
|
||||||
|
GLint prevViewport[4];
|
||||||
|
glGetIntegerv(GL_VIEWPORT, prevViewport);
|
||||||
|
|
||||||
|
// Save current FBO binding
|
||||||
|
GLint prevFbo;
|
||||||
|
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &prevFbo);
|
||||||
|
|
||||||
|
// Bind our FBO
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
|
||||||
|
glViewport(0, 0, fboWidth_, fboHeight_);
|
||||||
|
|
||||||
|
// Clear with dark blue background
|
||||||
|
glClearColor(0.05f, 0.05f, 0.1f, 1.0f);
|
||||||
|
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
|
||||||
|
glEnable(GL_DEPTH_TEST);
|
||||||
|
|
||||||
|
// Render the character model
|
||||||
|
charRenderer_->render(*camera_, camera_->getViewMatrix(), camera_->getProjectionMatrix());
|
||||||
|
|
||||||
|
// Restore previous FBO and viewport
|
||||||
|
glBindFramebuffer(GL_FRAMEBUFFER, static_cast<GLuint>(prevFbo));
|
||||||
|
glViewport(prevViewport[0], prevViewport[1], prevViewport[2], prevViewport[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterPreview::rotate(float yawDelta) {
|
||||||
|
modelYaw_ += yawDelta;
|
||||||
|
if (instanceId_ > 0 && charRenderer_) {
|
||||||
|
charRenderer_->setInstanceRotation(instanceId_, glm::vec3(0.0f, 0.0f, modelYaw_));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace rendering
|
||||||
|
} // namespace wowee
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "ui/character_create_screen.hpp"
|
#include "ui/character_create_screen.hpp"
|
||||||
|
#include "rendering/character_preview.hpp"
|
||||||
#include "game/game_handler.hpp"
|
#include "game/game_handler.hpp"
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
@ -28,6 +29,8 @@ CharacterCreateScreen::CharacterCreateScreen() {
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CharacterCreateScreen::~CharacterCreateScreen() = default;
|
||||||
|
|
||||||
void CharacterCreateScreen::reset() {
|
void CharacterCreateScreen::reset() {
|
||||||
std::memset(nameBuffer, 0, sizeof(nameBuffer));
|
std::memset(nameBuffer, 0, sizeof(nameBuffer));
|
||||||
raceIndex = 0;
|
raceIndex = 0;
|
||||||
|
|
@ -41,6 +44,30 @@ void CharacterCreateScreen::reset() {
|
||||||
statusMessage.clear();
|
statusMessage.clear();
|
||||||
statusIsError = false;
|
statusIsError = false;
|
||||||
updateAvailableClasses();
|
updateAvailableClasses();
|
||||||
|
|
||||||
|
// Reset preview tracking to force model reload on next render
|
||||||
|
prevRaceIndex_ = -1;
|
||||||
|
prevGenderIndex_ = -1;
|
||||||
|
prevSkin_ = -1;
|
||||||
|
prevFace_ = -1;
|
||||||
|
prevHairStyle_ = -1;
|
||||||
|
prevHairColor_ = -1;
|
||||||
|
prevFacialHair_ = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterCreateScreen::initializePreview(pipeline::AssetManager* am) {
|
||||||
|
if (!preview_) {
|
||||||
|
preview_ = std::make_unique<rendering::CharacterPreview>();
|
||||||
|
preview_->initialize(am);
|
||||||
|
}
|
||||||
|
// Force model reload
|
||||||
|
prevRaceIndex_ = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CharacterCreateScreen::update(float deltaTime) {
|
||||||
|
if (preview_) {
|
||||||
|
preview_->update(deltaTime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void CharacterCreateScreen::setStatus(const std::string& msg, bool isError) {
|
void CharacterCreateScreen::setStatus(const std::string& msg, bool isError) {
|
||||||
|
|
@ -62,23 +89,98 @@ void CharacterCreateScreen::updateAvailableClasses() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CharacterCreateScreen::updatePreviewIfNeeded() {
|
||||||
|
if (!preview_) return;
|
||||||
|
|
||||||
|
bool changed = (raceIndex != prevRaceIndex_ ||
|
||||||
|
genderIndex != prevGenderIndex_ ||
|
||||||
|
skin != prevSkin_ ||
|
||||||
|
face != prevFace_ ||
|
||||||
|
hairStyle != prevHairStyle_ ||
|
||||||
|
hairColor != prevHairColor_ ||
|
||||||
|
facialHair != prevFacialHair_);
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
preview_->loadCharacter(
|
||||||
|
allRaces[raceIndex],
|
||||||
|
static_cast<game::Gender>(genderIndex),
|
||||||
|
static_cast<uint8_t>(skin),
|
||||||
|
static_cast<uint8_t>(face),
|
||||||
|
static_cast<uint8_t>(hairStyle),
|
||||||
|
static_cast<uint8_t>(hairColor),
|
||||||
|
static_cast<uint8_t>(facialHair));
|
||||||
|
|
||||||
|
prevRaceIndex_ = raceIndex;
|
||||||
|
prevGenderIndex_ = genderIndex;
|
||||||
|
prevSkin_ = skin;
|
||||||
|
prevFace_ = face;
|
||||||
|
prevHairStyle_ = hairStyle;
|
||||||
|
prevHairColor_ = hairColor;
|
||||||
|
prevFacialHair_ = facialHair;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
||||||
|
// Render the preview to FBO before the ImGui frame
|
||||||
|
if (preview_) {
|
||||||
|
updatePreviewIfNeeded();
|
||||||
|
preview_->render();
|
||||||
|
}
|
||||||
|
|
||||||
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
|
ImVec2 displaySize = ImGui::GetIO().DisplaySize;
|
||||||
ImVec2 winSize(600, 520);
|
bool hasPreview = (preview_ && preview_->getTextureId() != 0);
|
||||||
ImGui::SetNextWindowSize(winSize, ImGuiCond_FirstUseEver);
|
float previewWidth = hasPreview ? 320.0f : 0.0f;
|
||||||
ImGui::SetNextWindowPos(ImVec2((displaySize.x - winSize.x) * 0.5f,
|
float controlsWidth = 540.0f;
|
||||||
(displaySize.y - winSize.y) * 0.5f),
|
float totalWidth = hasPreview ? (previewWidth + controlsWidth + 16.0f) : 600.0f;
|
||||||
ImGuiCond_FirstUseEver);
|
float totalHeight = 580.0f;
|
||||||
|
|
||||||
ImGui::Begin("Create Character", nullptr, ImGuiWindowFlags_NoCollapse);
|
ImGui::SetNextWindowSize(ImVec2(totalWidth, totalHeight), ImGuiCond_Always);
|
||||||
|
ImGui::SetNextWindowPos(ImVec2((displaySize.x - totalWidth) * 0.5f,
|
||||||
|
(displaySize.y - totalHeight) * 0.5f),
|
||||||
|
ImGuiCond_Always);
|
||||||
|
|
||||||
ImGui::Text("Create Character");
|
ImGui::Begin("Create Character", nullptr,
|
||||||
ImGui::Separator();
|
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize);
|
||||||
ImGui::Spacing();
|
|
||||||
|
if (hasPreview) {
|
||||||
|
// Left panel: 3D preview
|
||||||
|
ImGui::BeginChild("##preview_panel", ImVec2(previewWidth, -40.0f), false);
|
||||||
|
{
|
||||||
|
// Display the FBO texture (flip Y for OpenGL)
|
||||||
|
float imgW = previewWidth - 8.0f;
|
||||||
|
float imgH = imgW * (static_cast<float>(preview_->getHeight()) /
|
||||||
|
static_cast<float>(preview_->getWidth()));
|
||||||
|
if (imgH > totalHeight - 80.0f) {
|
||||||
|
imgH = totalHeight - 80.0f;
|
||||||
|
imgW = imgH * (static_cast<float>(preview_->getWidth()) /
|
||||||
|
static_cast<float>(preview_->getHeight()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Image(
|
||||||
|
static_cast<ImTextureID>(preview_->getTextureId()),
|
||||||
|
ImVec2(imgW, imgH),
|
||||||
|
ImVec2(0.0f, 1.0f), // UV top-left (flipped Y)
|
||||||
|
ImVec2(1.0f, 0.0f)); // UV bottom-right (flipped Y)
|
||||||
|
|
||||||
|
// Mouse drag rotation on the preview image
|
||||||
|
if (ImGui::IsItemHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
|
||||||
|
float deltaX = ImGui::GetIO().MouseDelta.x;
|
||||||
|
preview_->rotate(deltaX * 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Drag to rotate");
|
||||||
|
}
|
||||||
|
ImGui::EndChild();
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
|
||||||
|
// Right panel: controls
|
||||||
|
ImGui::BeginChild("##controls_panel", ImVec2(0, -40.0f), false);
|
||||||
|
}
|
||||||
|
|
||||||
// Name input
|
// Name input
|
||||||
ImGui::Text("Name:");
|
ImGui::Text("Name:");
|
||||||
ImGui::SameLine(100);
|
ImGui::SameLine(80);
|
||||||
ImGui::SetNextItemWidth(200);
|
ImGui::SetNextItemWidth(200);
|
||||||
ImGui::InputText("##name", nameBuffer, sizeof(nameBuffer));
|
ImGui::InputText("##name", nameBuffer, sizeof(nameBuffer));
|
||||||
|
|
||||||
|
|
@ -86,8 +188,8 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
||||||
|
|
||||||
// Race selection
|
// Race selection
|
||||||
ImGui::Text("Race:");
|
ImGui::Text("Race:");
|
||||||
ImGui::SameLine(100);
|
ImGui::Spacing();
|
||||||
ImGui::BeginGroup();
|
ImGui::Indent(10.0f);
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "Alliance:");
|
ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "Alliance:");
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
for (int i = 0; i < ALLIANCE_COUNT; ++i) {
|
for (int i = 0; i < ALLIANCE_COUNT; ++i) {
|
||||||
|
|
@ -120,13 +222,13 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
||||||
}
|
}
|
||||||
if (selected) ImGui::PopStyleColor();
|
if (selected) ImGui::PopStyleColor();
|
||||||
}
|
}
|
||||||
ImGui::EndGroup();
|
ImGui::Unindent(10.0f);
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Class selection
|
// Class selection
|
||||||
ImGui::Text("Class:");
|
ImGui::Text("Class:");
|
||||||
ImGui::SameLine(100);
|
ImGui::SameLine(80);
|
||||||
if (!availableClasses.empty()) {
|
if (!availableClasses.empty()) {
|
||||||
ImGui::BeginGroup();
|
ImGui::BeginGroup();
|
||||||
for (int i = 0; i < static_cast<int>(availableClasses.size()); ++i) {
|
for (int i = 0; i < static_cast<int>(availableClasses.size()); ++i) {
|
||||||
|
|
@ -145,7 +247,7 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
||||||
|
|
||||||
// Gender
|
// Gender
|
||||||
ImGui::Text("Gender:");
|
ImGui::Text("Gender:");
|
||||||
ImGui::SameLine(100);
|
ImGui::SameLine(80);
|
||||||
ImGui::RadioButton("Male", &genderIndex, 0);
|
ImGui::RadioButton("Male", &genderIndex, 0);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
ImGui::RadioButton("Female", &genderIndex, 1);
|
ImGui::RadioButton("Female", &genderIndex, 1);
|
||||||
|
|
@ -161,10 +263,13 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
||||||
ImGui::Text("Appearance");
|
ImGui::Text("Appearance");
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
auto slider = [](const char* label, int* val, int maxVal) {
|
float sliderWidth = hasPreview ? 180.0f : 200.0f;
|
||||||
|
float labelCol = hasPreview ? 100.0f : 120.0f;
|
||||||
|
|
||||||
|
auto slider = [&](const char* label, int* val, int maxVal) {
|
||||||
ImGui::Text("%s", label);
|
ImGui::Text("%s", label);
|
||||||
ImGui::SameLine(120);
|
ImGui::SameLine(labelCol);
|
||||||
ImGui::SetNextItemWidth(200);
|
ImGui::SetNextItemWidth(sliderWidth);
|
||||||
char id[32];
|
char id[32];
|
||||||
snprintf(id, sizeof(id), "##%s", label);
|
snprintf(id, sizeof(id), "##%s", label);
|
||||||
ImGui::SliderInt(id, val, 0, maxVal);
|
ImGui::SliderInt(id, val, 0, maxVal);
|
||||||
|
|
@ -176,18 +281,24 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) {
|
||||||
slider("Hair Color", &hairColor, game::getMaxHairColor(currentRace, currentGender));
|
slider("Hair Color", &hairColor, game::getMaxHairColor(currentRace, currentGender));
|
||||||
slider("Facial Feature", &facialHair, game::getMaxFacialFeature(currentRace, currentGender));
|
slider("Facial Feature", &facialHair, game::getMaxFacialFeature(currentRace, currentGender));
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Status message
|
// Status message
|
||||||
if (!statusMessage.empty()) {
|
if (!statusMessage.empty()) {
|
||||||
|
ImGui::Separator();
|
||||||
|
ImGui::Spacing();
|
||||||
ImVec4 color = statusIsError ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
|
ImVec4 color = statusIsError ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f);
|
||||||
ImGui::TextColored(color, "%s", statusMessage.c_str());
|
ImGui::TextColored(color, "%s", statusMessage.c_str());
|
||||||
ImGui::Spacing();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buttons
|
if (hasPreview) {
|
||||||
|
ImGui::EndChild(); // controls_panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom buttons (outside children)
|
||||||
|
ImGui::Separator();
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
if (ImGui::Button("Create", ImVec2(150, 35))) {
|
if (ImGui::Button("Create", ImVec2(150, 35))) {
|
||||||
std::string name(nameBuffer);
|
std::string name(nameBuffer);
|
||||||
if (name.empty()) {
|
if (name.empty()) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue