diff --git a/CMakeLists.txt b/CMakeLists.txt index 39e25756..026b4e31 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/include/core/application.hpp b/include/core/application.hpp index f8c064cc..e6e67241 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -204,12 +204,9 @@ private: std::vector pendingGameObjectSpawns_; void processGameObjectSpawnQueue(); - // Quest marker 3D models (billboarded above NPCs) - uint32_t questExclamationModelId_ = 0; - uint32_t questQuestionMarkModelId_ = 0; - std::unordered_map 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 diff --git a/include/rendering/quest_marker_renderer.hpp b/include/rendering/quest_marker_renderer.hpp new file mode 100644 index 00000000..47e4e044 --- /dev/null +++ b/include/rendering/quest_marker_renderer.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include + +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 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 diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index ada36027..c6b06d18 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -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; std::unique_ptr m2Renderer; std::unique_ptr minimap; + std::unique_ptr questMarkerRenderer; std::unique_ptr musicManager; std::unique_ptr footstepManager; std::unique_ptr activitySoundManager; diff --git a/src/core/application.cpp b/src/core/application.cpp index a9d5cc53..2ca9262f 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -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 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; } } diff --git a/src/rendering/quest_marker_renderer.cpp b/src/rendering/quest_marker_renderer.cpp new file mode 100644 index 00000000..046b615a --- /dev/null +++ b/src/rendering/quest_marker_renderer.cpp @@ -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 +#include +#include +#include +#include + +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 diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index bea857a7..74031546 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -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 @@ -330,6 +331,9 @@ bool Renderer::initialize(core::Window* win) { minimap.reset(); } + // Create quest marker renderer (initialized later with AssetManager) + questMarkerRenderer = std::make_unique(); + // Create M2 renderer (for doodads) m2Renderer = std::make_unique(); // 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) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 292d5629..e3e8e3fd 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -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);