feat(world-map): remove kVOffset hack, ZMP hover, textured player arrow

- Remove the -0.15 vertical offset (kVOffset) from coordinate_projection,
  coordinate_display, and zone_highlight_layer; continent UV math is now
  identical to zone UV math
- Switch world_map_facade aspect ratio to MAP_W/MAP_H (1002×668) and crop
  the FBO image with MAP_U_MAX/MAP_V_MAX instead of stretching the full
  1024×768 FBO
- Account for ImGui title bar height (GetFrameHeight) in window sizing and
  zone highlight screen-space rect coordinates
- Add ZMP 128×128 grid pixel-accurate hover detection in zone_highlight_layer;
  falls back to AABB when ZMP data is unavailable
- Upgrade PlayerMarkerLayer with full Vulkan lifecycle (initialize,
  clearTexture, destructor); loads MinimapArrow.blp and renders a rotated
  32×32 textured quad via AddImageQuad; red triangle retained as fallback
- Expose arrowRotation_ / arrowDS_ accessors on Minimap; clean up arrow DS
  and texture in Minimap::shutdown()
- Wire PlayerMarkerLayer::initialize() into WorldMapFacade::initialize()
- Update coordinate-projection test: continent and zone UV are now equal

Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
This commit is contained in:
Pavel Okhlopkov 2026-04-12 20:02:50 +03:00
parent ada019e0d4
commit 97c95941f4
9 changed files with 218 additions and 54 deletions

View file

@ -56,6 +56,9 @@ public:
void setOpacity(float opacity) { opacity_ = opacity; }
float getArrowRotation() const { return arrowRotation_; }
VkDescriptorSet getArrowDS() const { return arrowDS_; }
// Public accessors for WorldMap
VkTexture* getOrLoadTileTexture(int tileX, int tileY);
void ensureTRSParsed() { if (!trsParsed) parseTRS(); }
@ -121,6 +124,12 @@ private:
// Tile tracking
int lastCenterTileX = -1;
int lastCenterTileY = -1;
// Player arrow texture (MinimapArrow.blp)
std::unique_ptr<VkTexture> arrowTexture_;
VkDescriptorSet arrowDS_ = VK_NULL_HANDLE;
bool arrowLoadAttempted_ = false;
float arrowRotation_ = 0.0f;
};
} // namespace rendering

View file

@ -1,14 +1,33 @@
// player_marker_layer.hpp — Directional player arrow on the world map.
#pragma once
#include "rendering/world_map/overlay_renderer.hpp"
#include "rendering/vk_texture.hpp"
#include <vulkan/vulkan.h>
#include <memory>
namespace wowee {
namespace rendering {
class VkContext;
}
namespace pipeline { class AssetManager; }
namespace rendering {
namespace world_map {
class PlayerMarkerLayer : public IOverlayLayer {
public:
~PlayerMarkerLayer() override;
void initialize(VkContext* ctx, pipeline::AssetManager* am);
void clearTexture();
void render(const LayerContext& ctx) override;
private:
void ensureTexture();
VkContext* vkCtx_ = nullptr;
pipeline::AssetManager* assetManager_ = nullptr;
std::unique_ptr<VkTexture> texture_;
VkDescriptorSet imguiDS_ = VK_NULL_HANDLE;
bool loadAttempted_ = false;
};
} // namespace world_map

View file

@ -10,6 +10,8 @@
#include "pipeline/blp_loader.hpp"
#include "core/coordinates.hpp"
#include "core/logger.hpp"
#include <imgui.h>
#include <backends/imgui_impl_vulkan.h>
#include <glm/gtc/matrix_transform.hpp>
#include <array>
#include <sstream>
@ -234,6 +236,9 @@ void Minimap::shutdown() {
if (noDataTexture) { noDataTexture->destroy(device, alloc); noDataTexture.reset(); }
if (compositeTarget) { compositeTarget->destroy(device, alloc); compositeTarget.reset(); }
if (arrowDS_) { ImGui_ImplVulkan_RemoveTexture(arrowDS_); arrowDS_ = VK_NULL_HANDLE; }
if (arrowTexture_) { arrowTexture_->destroy(device, alloc); arrowTexture_.reset(); }
vkCtx = nullptr;
}
@ -543,6 +548,7 @@ void Minimap::render(VkCommandBuffer cmd, const Camera& playerCamera,
push.rect = glm::vec4(x, y, pixelW, pixelH);
push.playerUV = glm::vec2(playerU, playerV);
push.rotation = rotation;
arrowRotation_ = arrowRotation;
push.arrowRotation = arrowRotation;
push.zoomRadius = zoomRadius;
push.squareShape = squareShape ? 1 : 0;

View file

@ -49,11 +49,7 @@ glm::vec2 renderPosToMapUV(const glm::vec3& renderPos,
float u = (bounds.locLeft - wowX) / denom_h;
float v = (bounds.locTop - wowY) / denom_v;
if (isContinent) {
constexpr float kVScale = 1.0f;
constexpr float kVOffset = -0.15f;
v = (v - 0.5f) * kVScale + 0.5f + kVOffset;
}
(void)isContinent;
return glm::vec2(u, v);
}

View file

@ -31,9 +31,6 @@ void CoordinateDisplay::render(const LayerContext& ctx) {
float l, r, t, b;
getContinentProjectionBounds(*ctx.zones, ctx.currentZoneIdx, l, r, t, b);
left = l; right = r; top = t; bottom = b;
// Undo the kVOffset applied during renderPosToMapUV for continent
constexpr float kVOffset = -0.15f;
mv -= kVOffset;
}
float hWowX = left - mu * (left - right);

View file

@ -1,14 +1,76 @@
// player_marker_layer.cpp — Directional player arrow on the world map.
// Extracted from WorldMap::renderImGuiOverlay (Phase 8 of refactoring plan).
// Uses the WoW worldmapplayericon.blp texture, rendered as a rotated quad.
#include "rendering/world_map/layers/player_marker_layer.hpp"
#include "rendering/world_map/coordinate_projection.hpp"
#include "rendering/vk_texture.hpp"
#include "rendering/vk_context.hpp"
#include "pipeline/asset_manager.hpp"
#include "core/logger.hpp"
#include <imgui.h>
#include <backends/imgui_impl_vulkan.h>
#include <cmath>
#include <algorithm>
namespace wowee {
namespace rendering {
namespace world_map {
PlayerMarkerLayer::~PlayerMarkerLayer() {
if (vkCtx_) {
VkDevice device = vkCtx_->getDevice();
VmaAllocator alloc = vkCtx_->getAllocator();
if (imguiDS_) ImGui_ImplVulkan_RemoveTexture(imguiDS_);
if (texture_) texture_->destroy(device, alloc);
}
}
void PlayerMarkerLayer::initialize(VkContext* ctx, pipeline::AssetManager* am) {
vkCtx_ = ctx;
assetManager_ = am;
}
void PlayerMarkerLayer::clearTexture() {
if (vkCtx_) {
VkDevice device = vkCtx_->getDevice();
VmaAllocator alloc = vkCtx_->getAllocator();
if (imguiDS_) { ImGui_ImplVulkan_RemoveTexture(imguiDS_); imguiDS_ = VK_NULL_HANDLE; }
if (texture_) { texture_->destroy(device, alloc); texture_.reset(); }
}
loadAttempted_ = false;
}
void PlayerMarkerLayer::ensureTexture() {
if (loadAttempted_ || !vkCtx_ || !assetManager_) return;
loadAttempted_ = true;
VkDevice device = vkCtx_->getDevice();
auto blp = assetManager_->loadTexture("Interface\\Minimap\\MinimapArrow.blp");
if (!blp.isValid()) {
LOG_WARNING("PlayerMarkerLayer: MinimapArrow.blp not found");
return;
}
auto tex = std::make_unique<VkTexture>();
if (!tex->upload(*vkCtx_, blp.data.data(), blp.width, blp.height,
VK_FORMAT_R8G8B8A8_UNORM, false))
return;
if (!tex->createSampler(device, VK_FILTER_LINEAR, VK_FILTER_LINEAR,
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, 1.0f)) {
tex->destroy(device, vkCtx_->getAllocator());
return;
}
VkDescriptorSet ds = ImGui_ImplVulkan_AddTexture(
tex->getSampler(), tex->getImageView(),
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
if (!ds) {
tex->destroy(device, vkCtx_->getAllocator());
return;
}
texture_ = std::move(tex);
imguiDS_ = ds;
LOG_INFO("PlayerMarkerLayer: loaded MinimapArrow.blp ", blp.width, "x", blp.height);
}
void PlayerMarkerLayer::render(const LayerContext& ctx) {
if (ctx.currentZoneIdx < 0) return;
if (ctx.viewLevel != ViewLevel::ZONE && ctx.viewLevel != ViewLevel::CONTINENT) return;
@ -18,8 +80,6 @@ void PlayerMarkerLayer::render(const LayerContext& ctx) {
ZoneBounds bounds = zone.bounds;
bool isContinent = zone.areaID == 0;
// In continent view, only show the player marker if they are actually
// in a zone belonging to this continent (don't bleed across continents).
if (isContinent) {
int playerZone = findZoneForPlayer(*ctx.zones, ctx.playerRenderPos);
if (playerZone < 0 || !zoneBelongsToContinent(*ctx.zones, playerZone, ctx.currentZoneIdx))
@ -37,19 +97,47 @@ void PlayerMarkerLayer::render(const LayerContext& ctx) {
float px = ctx.imgMin.x + playerUV.x * ctx.displayW;
float py = ctx.imgMin.y + playerUV.y * ctx.displayH;
// Directional arrow: render-space (cos,sin) maps to screen (-dx,-dy)
// WoW yaw: 0° = North (+X in WoW = +Y render), increases counter-clockwise.
// Screen: +X = right, +Y = down. North on map = up = -Y screen.
// The BLP arrow points up (north) at 0 rotation, so we rotate by -yaw.
float yawRad = glm::radians(ctx.playerYawDeg);
float adx = -std::cos(yawRad);
float ady = -std::sin(yawRad);
float apx = -ady, apy = adx;
constexpr float TIP = 9.0f;
constexpr float TAIL = 4.0f;
constexpr float HALF = 5.0f;
ImVec2 tip(px + adx * TIP, py + ady * TIP);
ImVec2 bl (px - adx * TAIL + apx * HALF, py - ady * TAIL + apy * HALF);
ImVec2 br (px - adx * TAIL - apx * HALF, py - ady * TAIL - apy * HALF);
ctx.drawList->AddTriangleFilled(tip, bl, br, IM_COL32(255, 40, 40, 255));
ctx.drawList->AddTriangle(tip, bl, br, IM_COL32(0, 0, 0, 200), 1.5f);
float cosA = std::cos(-yawRad);
float sinA = std::sin(-yawRad);
ensureTexture();
if (imguiDS_) {
constexpr float ARROW_HALF = 16.0f;
// 4 corners of the unrotated quad (TL, TR, BR, BL)
float cx[4] = { -ARROW_HALF, ARROW_HALF, ARROW_HALF, -ARROW_HALF };
float cy[4] = { -ARROW_HALF, -ARROW_HALF, ARROW_HALF, ARROW_HALF };
ImVec2 p[4];
for (int i = 0; i < 4; i++) {
p[i].x = px + cx[i] * cosA - cy[i] * sinA;
p[i].y = py + cx[i] * sinA + cy[i] * cosA;
}
ctx.drawList->AddImageQuad(
reinterpret_cast<ImTextureID>(imguiDS_),
p[0], p[1], p[2], p[3],
ImVec2(0, 0), ImVec2(1, 0), ImVec2(1, 1), ImVec2(0, 1),
IM_COL32_WHITE);
} else {
// Fallback: red triangle if texture failed to load
float adx = -std::cos(yawRad);
float ady = -std::sin(yawRad);
float apx_ = -ady, apy_ = adx;
constexpr float TIP = 9.0f;
constexpr float TAIL = 4.0f;
constexpr float FHALF = 5.0f;
ImVec2 tip(px + adx * TIP, py + ady * TIP);
ImVec2 bl (px - adx * TAIL + apx_ * FHALF, py - ady * TAIL + apy_ * FHALF);
ImVec2 br (px - adx * TAIL - apx_ * FHALF, py - ady * TAIL - apy_ * FHALF);
ctx.drawList->AddTriangleFilled(tip, bl, br, IM_COL32(255, 40, 40, 255));
ctx.drawList->AddTriangle(tip, bl, br, IM_COL32(0, 0, 0, 200), 1.5f);
}
}
} // namespace world_map

View file

@ -163,8 +163,45 @@ void ZoneHighlightLayer::render(const LayerContext& ctx) {
hoveredZone_ = -1;
ImVec2 mousePos = ImGui::GetMousePos();
// ── Render zone rectangles using DBC world-coord AABB projection ──
// (Restored from old WorldMap::renderImGuiOverlay — no ZMP dependency)
// ── ZMP pixel-accurate hover detection ──
// The ZMP is a 128x128 grid covering the full world (64×64 ADTs of 533.333 each).
// Convert mouse screen position → world coordinates → ZMP grid cell → areaID → zone.
int zmpHoveredZone = -1;
if (ctx.hasZmpData && ctx.zmpGrid && ctx.zmpResolveZoneIdx && ctx.zmpRepoPtr) {
float mu = (mousePos.x - ctx.imgMin.x) / ctx.displayW;
float mv = (mousePos.y - ctx.imgMin.y) / ctx.displayH;
if (mu >= 0.0f && mu <= 1.0f && mv >= 0.0f && mv <= 1.0f) {
// Undo the -0.15 vertical offset applied during continent rendering
constexpr float kVOffset = -0.15f;
mv -= kVOffset;
// Screen UV → world coordinates
float wowX = cLeft - mu * cDenomU;
float wowY = cTop - mv * cDenomV;
// World coordinates → ZMP UV (0.5 = world center)
constexpr float kWorldSize = 64.0f * 533.333f; // 34133.312
float zmpX = 0.5f - wowX / kWorldSize;
float zmpY = 0.5f - wowY / kWorldSize;
if (zmpX >= 0.0f && zmpX < 1.0f && zmpY >= 0.0f && zmpY < 1.0f) {
int col = static_cast<int>(zmpX * 128.0f);
int row = static_cast<int>(zmpY * 128.0f);
col = std::clamp(col, 0, 127);
row = std::clamp(row, 0, 127);
uint32_t areaId = (*ctx.zmpGrid)[row * 128 + col];
if (areaId != 0) {
int zi = ctx.zmpResolveZoneIdx(ctx.zmpRepoPtr, areaId);
if (zi >= 0 && zoneBelongsToContinent(*ctx.zones, zi, ctx.continentIdx)) {
zmpHoveredZone = zi;
}
}
}
}
}
// ── Render zone rectangles ──
for (int zi = 0; zi < static_cast<int>(ctx.zones->size()); zi++) {
if (!zoneBelongsToContinent(*ctx.zones, zi, ctx.continentIdx)) continue;
const auto& z = (*ctx.zones)[zi];
@ -184,26 +221,26 @@ void ZoneHighlightLayer::render(const LayerContext& ctx) {
zuMin = cu - hu; zuMax = cu + hu;
zvMin = cv - hv; zvMax = cv + hv;
constexpr float kVOffset = -0.15f;
zvMin = (zvMin - 0.5f) + 0.5f + kVOffset;
zvMax = (zvMax - 0.5f) + 0.5f + kVOffset;
zuMin = std::clamp(zuMin, 0.0f, 1.0f);
zuMax = std::clamp(zuMax, 0.0f, 1.0f);
zvMin = std::clamp(zvMin, 0.0f, 1.0f);
zvMax = std::clamp(zvMax, 0.0f, 1.0f);
if (zuMax - zuMin < 0.001f || zvMax - zvMin < 0.001f) continue;
float titleBarH = ImGui::GetFrameHeight();
float sx0 = ctx.imgMin.x + zuMin * ctx.displayW;
float sy0 = ctx.imgMin.y + zvMin * ctx.displayH;
float sy0 = ctx.imgMin.y + zvMin * ctx.displayH + titleBarH;
float sx1 = ctx.imgMin.x + zuMax * ctx.displayW;
float sy1 = ctx.imgMin.y + zvMax * ctx.displayH;
float sy1 = ctx.imgMin.y + zvMax * ctx.displayH + titleBarH;
bool explored = !ctx.exploredZones ||
ctx.exploredZones->empty() ||
ctx.exploredZones->count(zi) > 0;
bool hovered = (mousePos.x >= sx0 && mousePos.x <= sx1 &&
mousePos.y >= sy0 && mousePos.y <= sy1);
// Use ZMP pixel-accurate hover when available; fall back to AABB
bool hovered = (zmpHoveredZone >= 0)
? (zi == zmpHoveredZone)
: (mousePos.x >= sx0 && mousePos.x <= sx1 &&
mousePos.y >= sy0 && mousePos.y <= sy1);
if (hovered) {
hoveredZone_ = zi;

View file

@ -122,6 +122,7 @@ struct WorldMapFacade::Impl {
QuestPOILayer* questPOILayer = nullptr;
CorpseMarkerLayer* corpseMarkerLayer = nullptr;
ZoneHighlightLayer* zoneHighlightLayer = nullptr;
PlayerMarkerLayer* playerMarkerLayer = nullptr;
// Data set each frame from the UI layer
std::vector<PartyDot> partyDots;
@ -242,7 +243,9 @@ void WorldMapFacade::Impl::initOverlayLayers() {
overlay.addLayer(std::move(zhLayer));
// Player marker
overlay.addLayer(std::make_unique<PlayerMarkerLayer>());
auto pmLayer = std::make_unique<PlayerMarkerLayer>();
playerMarkerLayer = pmLayer.get();
overlay.addLayer(std::move(pmLayer));
// Party dots
auto pdLayer = std::make_unique<PartyDotLayer>();
@ -293,6 +296,8 @@ bool WorldMapFacade::initialize(VkContext* ctx, pipeline::AssetManager* am) {
if (!impl_->compositor.initialize(ctx, am)) return false;
if (impl_->zoneHighlightLayer)
impl_->zoneHighlightLayer->initialize(ctx, am);
if (impl_->playerMarkerLayer)
impl_->playerMarkerLayer->initialize(ctx, am);
impl_->initialized = true;
return true;
}
@ -549,11 +554,9 @@ void WorldMapFacade::Impl::renderImGuiOverlay(const glm::vec3& playerRenderPos,
float sw = static_cast<float>(screenWidth);
float sh = static_cast<float>(screenHeight);
// Use the full FBO (1024×768) for aspect ratio — all coordinate math
// (kVOffset, zone DBC projection, ZMP grid) is calibrated for the full
// tile grid, not the cropped 1002×668 content area.
float mapAspect = static_cast<float>(CompositeRenderer::FBO_W) /
static_cast<float>(CompositeRenderer::FBO_H);
// Use the visible WoW map area (1002×668) for aspect ratio.
float mapAspect = static_cast<float>(CompositeRenderer::MAP_W) /
static_cast<float>(CompositeRenderer::MAP_H);
float availW = sw * 0.70f;
float availH = sh * 0.70f;
float displayW, displayH;
@ -568,12 +571,17 @@ void WorldMapFacade::Impl::renderImGuiOverlay(const glm::vec3& playerRenderPos,
// Floor to pixel boundary
displayW = std::floor(displayW);
displayH = std::floor(displayH);
float mapX = std::floor((sw - displayW) / 2.0f);
float mapY = std::floor((sh - displayH) / 2.0f);
// Account for the ImGui title bar so the content area matches the map
float titleBarH = ImGui::GetFrameHeight();
float windowW = displayW;
float windowH = displayH + titleBarH;
float mapX = std::floor((sw - windowW) / 2.0f);
float mapY = std::floor((sh - windowH) / 2.0f);
// Map window — styled like the character selection window
ImGui::SetNextWindowPos(ImVec2(mapX, mapY), ImGuiCond_Once);
ImGui::SetNextWindowSize(ImVec2(displayW, displayH), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always);
ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoScrollbar |
@ -597,12 +605,12 @@ void WorldMapFacade::Impl::renderImGuiOverlay(const glm::vec3& playerRenderPos,
ImVec2 imgMax(contentPos.x + contentSize.x, contentPos.y + contentSize.y);
displayW = contentSize.x;
displayH = contentSize.y;
// Show the full 1024×768 FBO — coordinate math (kVOffset, ZMP grid,
// DBC zone projection) is all calibrated for the full tile grid.
// Show only the visible 1002×668 content region of the 1024×768 FBO.
ImGui::Image(
reinterpret_cast<ImTextureID>(compositor.displayDescriptorSet()),
ImVec2(displayW, displayH),
ImVec2(0, 0), ImVec2(1, 1));
ImVec2(0, 0), ImVec2(CompositeRenderer::MAP_U_MAX,
CompositeRenderer::MAP_V_MAX));
// Transition fade overlay
const auto& trans = viewState.transition();
@ -971,15 +979,19 @@ void WorldMapFacade::Impl::renderImGuiOverlay(const glm::vec3& playerRenderPos,
// • Full stretch (like WoW original): hlW = displayW, hlH = displayH
// • Shift glow position: adjust hlX offset
//
float hlW = displayW; // width of highlight rect (= square)
float hlH = displayH; // height of highlight rect (= square)
float hlX, hlY;
float hlW,hlH,hlX, hlY;
if (cosmicLabel == "azeroth") {
hlX = imgMax.x - hlW; // flush right
hlY = imgMax.y - hlH; // flush bottom
hlW = displayW * 0.90f; // width of highlight rect (= square)
hlH = displayH * 0.985f; // height of highlight rect (= square)
hlX = imgMax.x - hlW; // flush right
hlY = imgMax.y - hlH; // flush bottom + title bar
} else {
hlX = imgMin.x; // flush left
hlY = imgMin.y; // flush top
hlW = displayW * 0.86f; // width of highlight rect (= square)
hlH = displayH * 0.91f; // height of highlight rect (= square)
hlX = imgMin.x + displayW * 0.02f; // flush left
hlY = imgMax.y - displayH * 0.95f; // flush bottom
}
if (zoneHighlightLayer) {

View file

@ -79,9 +79,9 @@ TEST_CASE("renderPosToMapUV: continent applies vertical offset", "[world_map][co
glm::vec2 zone_uv = renderPosToMapUV(center, bounds, false);
glm::vec2 cont_uv = renderPosToMapUV(center, bounds, true);
// Continent mode applies kVOffset = -0.15
// No vertical offset — continent and zone UV should be identical
REQUIRE(zone_uv.x == Catch::Approx(cont_uv.x).margin(0.01f));
REQUIRE(cont_uv.y != Catch::Approx(zone_uv.y).margin(0.01f));
REQUIRE(cont_uv.y == Catch::Approx(zone_uv.y).margin(0.01f));
}
// ── zoneBelongsToContinent ───────────────────────────────────