Implement WoW-style 3D billboard quest markers

Replace 2D ImGui text markers with proper 3D billboard sprites using BLP textures.

Features:
- Billboard rendering using Interface\GossipFrame\ BLP textures (yellow !, yellow ?, grey ?)
- WoW-style visual effects: bob animation, distance-based scaling, glow pass, distance fade
- Proper NPC height positioning with bounding box detection
- Camera-facing quads with depth testing but no depth write
- Shader-based alpha modulation for glow and fade effects

Technical changes:
- Created QuestMarkerRenderer class with billboard sprite system
- Integrated into Renderer initialization for both online and offline terrain loading
- Rewrote updateQuestMarkers() to use billboard system instead of M2 models
- Disabled old 2D ImGui renderQuestMarkers() in game_screen.cpp
- Added debug logging for initialization and marker tracking

Quest markers now render with proper WoW visual fidelity.
This commit is contained in:
Kelsi 2026-02-09 23:41:38 -08:00
parent 084a79a6bc
commit 71d14b77c9
8 changed files with 407 additions and 63 deletions

View file

@ -146,6 +146,7 @@ set(WOWEE_SOURCES
src/rendering/character_preview.cpp
src/rendering/wmo_renderer.cpp
src/rendering/m2_renderer.cpp
src/rendering/quest_marker_renderer.cpp
src/rendering/minimap.cpp
src/rendering/world_map.cpp
src/rendering/swim_effects.cpp

View file

@ -204,12 +204,9 @@ 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();
// Quest marker billboard sprites (above NPCs)
void loadQuestMarkerModels(); // Now loads BLP textures
void updateQuestMarkers(); // Updates billboard positions
};
} // namespace core

View file

@ -0,0 +1,71 @@
#pragma once
#include <glm/glm.hpp>
#include <cstdint>
#include <vector>
#include <unordered_map>
namespace wowee {
namespace pipeline { class AssetManager; }
namespace rendering {
class Camera;
/**
* Renders quest markers as billboarded sprites above NPCs
* Uses BLP textures from Interface\GossipFrame\
*/
class QuestMarkerRenderer {
public:
QuestMarkerRenderer();
~QuestMarkerRenderer();
bool initialize(pipeline::AssetManager* assetManager);
void shutdown();
/**
* Add or update a quest marker at a position
* @param guid NPC GUID
* @param position World position (NPC base position)
* @param markerType 0=available(!), 1=turnin(?), 2=incomplete(?)
* @param boundingHeight NPC bounding height (optional, default 2.0f)
*/
void setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight = 2.0f);
/**
* Remove a quest marker
*/
void removeMarker(uint64_t guid);
/**
* Clear all markers
*/
void clear();
/**
* Render all quest markers (call after world rendering, before UI)
*/
void render(const Camera& camera);
private:
struct Marker {
glm::vec3 position;
int type; // 0=available, 1=turnin, 2=incomplete
float boundingHeight = 2.0f;
};
std::unordered_map<uint64_t, Marker> markers_;
// OpenGL resources
uint32_t vao_ = 0;
uint32_t vbo_ = 0;
uint32_t shaderProgram_ = 0;
uint32_t textures_[3] = {0, 0, 0}; // available, turnin, incomplete
void createQuad();
void loadTextures(pipeline::AssetManager* assetManager);
void createShader();
};
} // namespace rendering
} // namespace wowee

View file

@ -32,6 +32,7 @@ class CharacterRenderer;
class WMORenderer;
class M2Renderer;
class Minimap;
class QuestMarkerRenderer;
class Shader;
class Renderer {
@ -105,6 +106,7 @@ public:
WMORenderer* getWMORenderer() const { return wmoRenderer.get(); }
M2Renderer* getM2Renderer() const { return m2Renderer.get(); }
Minimap* getMinimap() const { return minimap.get(); }
QuestMarkerRenderer* getQuestMarkerRenderer() const { return questMarkerRenderer.get(); }
const std::string& getCurrentZoneName() const { return currentZoneName; }
// Third-person character follow
@ -177,6 +179,7 @@ private:
std::unique_ptr<WMORenderer> wmoRenderer;
std::unique_ptr<M2Renderer> m2Renderer;
std::unique_ptr<Minimap> minimap;
std::unique_ptr<QuestMarkerRenderer> questMarkerRenderer;
std::unique_ptr<audio::MusicManager> musicManager;
std::unique_ptr<audio::FootstepManager> footstepManager;
std::unique_ptr<audio::ActivitySoundManager> activitySoundManager;

View file

@ -23,6 +23,7 @@
#include "rendering/wmo_renderer.hpp"
#include "rendering/m2_renderer.hpp"
#include "rendering/minimap.hpp"
#include "rendering/quest_marker_renderer.hpp"
#include "rendering/loading_screen.hpp"
#include "audio/music_manager.hpp"
#include "audio/footstep_manager.hpp"
@ -3001,67 +3002,56 @@ void Application::loadQuestMarkerModels() {
}
void Application::updateQuestMarkers() {
if (!gameHandler || !renderer || questExclamationModelId_ == 0) {
if (!gameHandler || !renderer) {
return;
}
auto* questMarkerRenderer = renderer->getQuestMarkerRenderer();
if (!questMarkerRenderer) {
static bool logged = false;
if (!logged) {
LOG_INFO("updateQuestMarkers: skipped - gameHandler=", (gameHandler ? "yes" : "no"),
" renderer=", (renderer ? "yes" : "no"),
" questExclamationModelId=", questExclamationModelId_);
LOG_WARNING("QuestMarkerRenderer not available!");
logged = true;
}
return;
}
auto* m2Renderer = renderer->getM2Renderer();
if (!m2Renderer) return;
const auto& questStatuses = gameHandler->getNpcQuestStatuses();
static int logCounter = 0;
if (++logCounter % 300 == 0) { // Log every ~10 seconds at 30fps
LOG_INFO("Quest markers: ", questStatuses.size(), " NPCs with status, ",
questMarkerInstances_.size(), " markers active");
LOG_INFO("Quest markers: ", questStatuses.size(), " NPCs with quest status");
}
// 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);
}
// Clear all markers (we'll re-add active ones)
questMarkerRenderer->clear();
// Update or create markers for NPCs with quest status
static bool firstRun = true;
int markersAdded = 0;
// Add 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;
// Determine marker type
int markerType = -1; // -1 = no marker
using game::QuestGiverStatus;
switch (status) {
case QuestGiverStatus::AVAILABLE:
case QuestGiverStatus::AVAILABLE_LOW:
markerModelId = questExclamationModelId_;
shouldShow = true;
markerType = 0; // Available (yellow !)
break;
case QuestGiverStatus::REWARD:
markerModelId = questQuestionMarkModelId_;
shouldShow = true;
markerType = 1; // Turn-in (yellow ?)
break;
case QuestGiverStatus::INCOMPLETE:
// Gray ? - for now just use regular ? (could load yellow variant later)
markerModelId = questQuestionMarkModelId_;
shouldShow = false; // Don't show incomplete markers
markerType = 2; // Incomplete (grey ?)
break;
default:
shouldShow = false;
break;
}
if (markerType < 0) continue;
// Get NPC entity position
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (!entity) continue;
@ -3069,35 +3059,22 @@ void Application::updateQuestMarkers() {
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = coords::canonicalToRender(canonical);
// Offset marker above NPC head
// Get NPC bounding height for proper marker positioning
glm::vec3 boundsCenter;
float boundsRadius = 0.0f;
float heightOffset = 3.0f;
float boundingHeight = 2.0f; // Default
if (getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) {
heightOffset = boundsRadius * 2.0f + 1.0f;
boundingHeight = boundsRadius * 2.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);
}
// Set the marker (renderer will handle positioning, bob, glow, etc.)
questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight);
markersAdded++;
}
if (firstRun && markersAdded > 0) {
LOG_INFO("Quest markers: Added ", markersAdded, " markers on first run");
firstRun = false;
}
}

View file

@ -0,0 +1,280 @@
#include "rendering/quest_marker_renderer.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 <SDL2/SDL.h>
#include <cmath>
namespace wowee { namespace rendering {
QuestMarkerRenderer::QuestMarkerRenderer() {
}
QuestMarkerRenderer::~QuestMarkerRenderer() {
shutdown();
}
bool QuestMarkerRenderer::initialize(pipeline::AssetManager* assetManager) {
if (!assetManager) {
LOG_WARNING("QuestMarkerRenderer: No AssetManager provided");
return false;
}
LOG_INFO("QuestMarkerRenderer: Initializing...");
createShader();
createQuad();
loadTextures(assetManager);
LOG_INFO("QuestMarkerRenderer: Initialization complete");
return true;
}
void QuestMarkerRenderer::shutdown() {
if (vao_) glDeleteVertexArrays(1, &vao_);
if (vbo_) glDeleteBuffers(1, &vbo_);
if (shaderProgram_) glDeleteProgram(shaderProgram_);
for (int i = 0; i < 3; ++i) {
if (textures_[i]) glDeleteTextures(1, &textures_[i]);
}
markers_.clear();
}
void QuestMarkerRenderer::createQuad() {
// Billboard quad vertices (centered, 1 unit size)
float vertices[] = {
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // bottom-left
0.5f, -0.5f, 0.0f, 1.0f, 1.0f, // bottom-right
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // top-right
-0.5f, 0.5f, 0.0f, 0.0f, 0.0f, // top-left
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // bottom-left
0.5f, 0.5f, 0.0f, 1.0f, 0.0f // top-right
};
glGenVertexArrays(1, &vao_);
glGenBuffers(1, &vbo_);
glBindVertexArray(vao_);
glBindBuffer(GL_ARRAY_BUFFER, vbo_);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// Position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// Texture coord attribute
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
}
void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) {
const char* paths[3] = {
"Interface\\GossipFrame\\AvailableQuestIcon.blp",
"Interface\\GossipFrame\\ActiveQuestIcon.blp",
"Interface\\GossipFrame\\IncompleteQuestIcon.blp"
};
for (int i = 0; i < 3; ++i) {
pipeline::BLPImage blp = assetManager->loadTexture(paths[i]);
if (!blp.isValid()) {
LOG_WARNING("Failed to load quest marker texture: ", paths[i]);
continue;
}
glGenTextures(1, &textures_[i]);
glBindTexture(GL_TEXTURE_2D, textures_[i]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, blp.width, blp.height,
0, GL_RGBA, GL_UNSIGNED_BYTE, blp.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_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glGenerateMipmap(GL_TEXTURE_2D);
LOG_INFO("Loaded quest marker texture: ", paths[i]);
}
glBindTexture(GL_TEXTURE_2D, 0);
}
void QuestMarkerRenderer::createShader() {
const char* vertexShaderSource = R"(
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
TexCoord = aTexCoord;
}
)";
const char* fragmentShaderSource = R"(
#version 330 core
in vec2 TexCoord;
out vec4 FragColor;
uniform sampler2D markerTexture;
uniform float uAlpha;
void main() {
vec4 texColor = texture(markerTexture, TexCoord);
if (texColor.a < 0.1)
discard;
FragColor = vec4(texColor.rgb, texColor.a * uAlpha);
}
)";
// Compile vertex shader
uint32_t vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr);
glCompileShader(vertexShader);
// Compile fragment shader
uint32_t fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr);
glCompileShader(fragmentShader);
// Link shader program
shaderProgram_ = glCreateProgram();
glAttachShader(shaderProgram_, vertexShader);
glAttachShader(shaderProgram_, fragmentShader);
glLinkProgram(shaderProgram_);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);
}
void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight) {
markers_[guid] = {position, markerType, boundingHeight};
}
void QuestMarkerRenderer::removeMarker(uint64_t guid) {
markers_.erase(guid);
}
void QuestMarkerRenderer::clear() {
markers_.clear();
}
void QuestMarkerRenderer::render(const Camera& camera) {
if (markers_.empty() || !shaderProgram_ || !vao_) return;
// WoW-style quest marker tuning parameters
constexpr float BASE_SIZE = 0.65f; // Base world-space size
constexpr float HEIGHT_OFFSET = 2.1f; // Height above NPC bounds
constexpr float BOB_AMPLITUDE = 0.10f; // Bob animation amplitude
constexpr float BOB_FREQUENCY = 1.25f; // Bob frequency (Hz)
constexpr float MIN_DIST = 4.0f; // Near clamp
constexpr float MAX_DIST = 90.0f; // Far fade-out start
constexpr float FADE_RANGE = 25.0f; // Fade-out range
constexpr float GLOW_ALPHA = 0.35f; // Glow pass alpha
// Get time for bob animation
float timeSeconds = SDL_GetTicks() / 1000.0f;
glEnable(GL_BLEND);
glEnable(GL_DEPTH_TEST);
glDepthMask(GL_FALSE); // Don't write to depth buffer
glUseProgram(shaderProgram_);
glm::mat4 view = camera.getViewMatrix();
glm::mat4 projection = camera.getProjectionMatrix();
glm::vec3 cameraPos = camera.getPosition();
int viewLoc = glGetUniformLocation(shaderProgram_, "view");
int projLoc = glGetUniformLocation(shaderProgram_, "projection");
int modelLoc = glGetUniformLocation(shaderProgram_, "model");
int alphaLoc = glGetUniformLocation(shaderProgram_, "uAlpha");
glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
glUniformMatrix4fv(projLoc, 1, GL_FALSE, glm::value_ptr(projection));
glBindVertexArray(vao_);
// Get camera right and up vectors for billboarding
glm::vec3 cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]);
glm::vec3 cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]);
for (const auto& [guid, marker] : markers_) {
if (marker.type < 0 || marker.type > 2) continue;
if (!textures_[marker.type]) continue;
// Calculate distance for LOD and culling
glm::vec3 toCamera = cameraPos - marker.position;
float dist = glm::length(toCamera);
// Calculate fade alpha
float fadeAlpha = 1.0f;
if (dist > MAX_DIST) {
float t = glm::clamp((dist - MAX_DIST) / FADE_RANGE, 0.0f, 1.0f);
t = t * t * (3.0f - 2.0f * t); // Smoothstep
fadeAlpha = 1.0f - t;
}
if (fadeAlpha <= 0.001f) continue; // Cull if fully faded
// Distance-based scaling (mild compensation for readability)
float distScale = 1.0f;
if (dist > MIN_DIST) {
float t = glm::clamp((dist - 5.0f) / 55.0f, 0.0f, 1.0f);
distScale = 1.0f + 0.35f * t;
}
float size = BASE_SIZE * distScale;
size = glm::clamp(size, BASE_SIZE * 0.9f, BASE_SIZE * 1.6f);
// Bob animation
float bob = std::sin(timeSeconds * BOB_FREQUENCY * 2.0f * 3.14159f) * BOB_AMPLITUDE;
// Position marker above NPC with bob
glm::vec3 markerPos = marker.position;
markerPos.z += marker.boundingHeight + HEIGHT_OFFSET + bob;
// Build billboard matrix (camera-facing quad)
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, markerPos);
// Billboard: align quad to face camera
model[0] = glm::vec4(cameraRight * size, 0.0f);
model[1] = glm::vec4(cameraUp * size, 0.0f);
model[2] = glm::vec4(glm::cross(cameraRight, cameraUp), 0.0f);
glBindTexture(GL_TEXTURE_2D, textures_[marker.type]);
// Glow pass (subtle additive glow for available/turnin markers)
if (marker.type == 0 || marker.type == 1) { // Available or turnin
glBlendFunc(GL_SRC_ALPHA, GL_ONE); // Additive blending
glUniform1f(alphaLoc, fadeAlpha * GLOW_ALPHA); // Reduced alpha for glow
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 6);
// Restore standard alpha blending for main pass
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
// Main pass with fade alpha
glUniform1f(alphaLoc, fadeAlpha);
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
glDrawArrays(GL_TRIANGLES, 0, 6);
}
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
glDepthMask(GL_TRUE);
glDisable(GL_BLEND);
}
}} // namespace wowee::rendering

View file

@ -18,6 +18,7 @@
#include "rendering/wmo_renderer.hpp"
#include "rendering/m2_renderer.hpp"
#include "rendering/minimap.hpp"
#include "rendering/quest_marker_renderer.hpp"
#include "rendering/shader.hpp"
#include "pipeline/m2_loader.hpp"
#include <algorithm>
@ -330,6 +331,9 @@ bool Renderer::initialize(core::Window* win) {
minimap.reset();
}
// Create quest marker renderer (initialized later with AssetManager)
questMarkerRenderer = std::make_unique<QuestMarkerRenderer>();
// Create M2 renderer (for doodads)
m2Renderer = std::make_unique<M2Renderer>();
// Note: M2 renderer needs asset manager, will be initialized when terrain loads
@ -1871,6 +1875,11 @@ void Renderer::renderWorld(game::World* world) {
waterRenderer->render(*camera, time);
}
// Render quest markers (billboards above NPCs)
if (questMarkerRenderer && camera) {
questMarkerRenderer->render(*camera);
}
// Full-screen underwater tint so WMO/M2/characters also feel submerged.
if (false && underwater && underwaterOverlayShader && underwaterOverlayVAO) {
glDisable(GL_DEPTH_TEST);
@ -2224,6 +2233,9 @@ bool Renderer::loadTestTerrain(pipeline::AssetManager* assetManager, const std::
if (movementSoundManager) {
movementSoundManager->initialize(assetManager);
}
if (questMarkerRenderer) {
questMarkerRenderer->initialize(assetManager);
}
cachedAssetManager = assetManager;
}
@ -2323,6 +2335,9 @@ bool Renderer::loadTerrainArea(const std::string& mapName, int centerX, int cent
if (movementSoundManager && cachedAssetManager) {
movementSoundManager->initialize(cachedAssetManager);
}
if (questMarkerRenderer && cachedAssetManager) {
questMarkerRenderer->initialize(cachedAssetManager);
}
// Wire ambient sound manager to terrain manager for emitter registration
if (terrainManager && ambientSoundManager) {

View file

@ -124,7 +124,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderVendorWindow(gameHandler);
renderTrainerWindow(gameHandler);
renderTaxiWindow(gameHandler);
renderQuestMarkers(gameHandler); // 2D markers (3D M2 files not in MPQ)
// renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now
renderMinimapMarkers(gameHandler);
renderDeathScreen(gameHandler);
renderResurrectDialog(gameHandler);