Implement 3D quest markers using M2 models

Loads and renders actual quest marker M2 models from
World\Generic\PassiveDoodads\Quest\ as floating 3D objects above NPCs
based on quest status, replacing 2D ImGui text markers.

Features:
- Loads QuestExclamation.m2 (yellow !) for available quests
- Loads QuestQuestionMark.m2 (silver ?) for completable quests
- Updates marker positions dynamically as NPCs move
- Automatically spawns/despawns markers based on quest status changes
- Positions markers above NPC heads using render bounds

Quest markers are now proper 3D assets consistent with WoW 3.3.5a client.
This commit is contained in:
Kelsi 2026-02-09 23:05:23 -08:00
parent f60b22a633
commit a4c5f35397
2 changed files with 146 additions and 0 deletions

View file

@ -203,6 +203,13 @@ private:
};
std::vector<PendingGameObjectSpawn> pendingGameObjectSpawns_;
void processGameObjectSpawnQueue();
// Quest marker 3D models (billboarded above NPCs)
uint32_t questExclamationModelId_ = 0;
uint32_t questQuestionMarkModelId_ = 0;
std::unordered_map<uint64_t, uint32_t> questMarkerInstances_; // npcGuid → marker instanceId
void loadQuestMarkerModels();
void updateQuestMarkers();
};
} // namespace core

View file

@ -327,6 +327,8 @@ void Application::setState(AppState newState) {
}
});
}
// Load quest marker models
loadQuestMarkerModels();
break;
}
case AppState::DISCONNECTED:
@ -403,6 +405,9 @@ void Application::update(float deltaTime) {
npcManager->update(deltaTime, renderer->getCharacterRenderer());
}
// Update 3D quest markers above NPCs
updateQuestMarkers();
// Sync server run speed to camera controller
if (renderer && gameHandler && renderer->getCameraController()) {
renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed());
@ -2981,5 +2986,139 @@ void Application::despawnOnlineGameObject(uint64_t guid) {
LOG_INFO("Despawned gameobject: guid=0x", std::hex, guid, std::dec);
}
void Application::loadQuestMarkerModels() {
if (!assetManager || !renderer) return;
auto* m2Renderer = renderer->getM2Renderer();
if (!m2Renderer) return;
// Load quest exclamation mark (yellow !)
{
std::string path = "World\\Generic\\PassiveDoodads\\Quest\\QuestExclamation.m2";
std::vector<uint8_t> m2Data = assetManager->readFile(path);
if (!m2Data.empty()) {
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (!model.vertices.empty()) {
questExclamationModelId_ = 60000; // High ID to avoid collision
if (m2Renderer->loadModel(model, questExclamationModelId_)) {
LOG_INFO("Loaded quest marker: ", path);
} else {
LOG_WARNING("Failed to upload quest marker to GPU: ", path);
}
} else {
LOG_WARNING("Failed to parse quest marker: ", path);
}
} else {
LOG_WARNING("Failed to read quest marker: ", path);
}
}
// Load quest question mark (silver ?)
{
std::string path = "World\\Generic\\PassiveDoodads\\Quest\\QuestQuestionMark.m2";
std::vector<uint8_t> m2Data = assetManager->readFile(path);
if (!m2Data.empty()) {
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (!model.vertices.empty()) {
questQuestionMarkModelId_ = 60001;
if (m2Renderer->loadModel(model, questQuestionMarkModelId_)) {
LOG_INFO("Loaded quest marker: ", path);
} else {
LOG_WARNING("Failed to upload quest marker to GPU: ", path);
}
} else {
LOG_WARNING("Failed to parse quest marker: ", path);
}
} else {
LOG_WARNING("Failed to read quest marker: ", path);
}
}
}
void Application::updateQuestMarkers() {
if (!gameHandler || !renderer || questExclamationModelId_ == 0) return;
auto* m2Renderer = renderer->getM2Renderer();
if (!m2Renderer) return;
const auto& questStatuses = gameHandler->getNpcQuestStatuses();
// Remove markers for NPCs that no longer have quest status
std::vector<uint64_t> toRemove;
for (const auto& [guid, instanceId] : questMarkerInstances_) {
if (questStatuses.find(guid) == questStatuses.end()) {
m2Renderer->removeInstance(instanceId);
toRemove.push_back(guid);
}
}
for (uint64_t guid : toRemove) {
questMarkerInstances_.erase(guid);
}
// Update or create markers for NPCs with quest status
for (const auto& [guid, status] : questStatuses) {
// Determine which marker model to use
uint32_t markerModelId = 0;
bool shouldShow = false;
using game::QuestGiverStatus;
switch (status) {
case QuestGiverStatus::AVAILABLE:
case QuestGiverStatus::AVAILABLE_LOW:
markerModelId = questExclamationModelId_;
shouldShow = true;
break;
case QuestGiverStatus::REWARD:
markerModelId = questQuestionMarkModelId_;
shouldShow = true;
break;
case QuestGiverStatus::INCOMPLETE:
// Gray ? - for now just use regular ? (could load yellow variant later)
markerModelId = questQuestionMarkModelId_;
shouldShow = false; // Don't show incomplete markers
break;
default:
shouldShow = false;
break;
}
// Get NPC entity position
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (!entity) continue;
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = coords::canonicalToRender(canonical);
// Offset marker above NPC head
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
float heightOffset = 3.0f;
if (getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
heightOffset = boundsRadius * 2.0f + 1.0f;
}
renderPos.z += heightOffset;
if (shouldShow && markerModelId != 0) {
// Check if marker already exists
auto it = questMarkerInstances_.find(guid);
if (it != questMarkerInstances_.end()) {
// Update existing marker position
m2Renderer->setInstancePosition(it->second, renderPos);
} else {
// Create new marker instance (billboarded, no rotation needed)
uint32_t instanceId = m2Renderer->createInstance(
markerModelId, renderPos, glm::vec3(0.0f), 1.0f);
questMarkerInstances_[guid] = instanceId;
}
} else {
// Remove marker if it exists but shouldn't show
auto it = questMarkerInstances_.find(guid);
if (it != questMarkerInstances_.end()) {
m2Renderer->removeInstance(it->second);
questMarkerInstances_.erase(it);
}
}
}
}
} // namespace core
} // namespace wowee