Add WMO portal culling infrastructure and fix single-player character flow

Portal-based visibility culling for WMO rendering (disabled by default,
needs debugging for complex WMOs like Stormwind). Skip character creation
screen when characters already exist in single-player mode.
This commit is contained in:
Kelsi 2026-02-05 15:31:00 -08:00
parent ca84384402
commit 41ac8f646e
6 changed files with 262 additions and 3 deletions

View file

@ -100,6 +100,14 @@ struct WMOPortalPlane {
float distance;
};
// WMO Portal Reference (MOPR chunk) - links portals to groups
struct WMOPortalRef {
uint16_t portalIndex; // Index into portals array
uint16_t groupIndex; // Group on other side of portal
int16_t side; // Which side of portal plane (-1 or 1)
uint16_t padding;
};
// WMO Liquid (MLIQ chunk data)
struct WMOLiquid {
uint32_t xVerts = 0; // Vertices in X direction
@ -192,6 +200,7 @@ struct WMOModel {
std::vector<WMOPortal> portals;
std::vector<WMOPortalPlane> portalPlanes;
std::vector<glm::vec3> portalVertices;
std::vector<WMOPortalRef> portalRefs; // MOPR chunk - portal-to-group links
// Lights
std::vector<WMOLight> lights;

View file

@ -20,6 +20,7 @@ namespace rendering {
class Camera;
class Shader;
class Frustum;
/**
* WMO (World Model Object) Renderer
@ -129,6 +130,17 @@ public:
*/
void setFrustumCulling(bool enabled) { frustumCulling = enabled; }
/**
* Enable/disable portal-based visibility culling
*/
void setPortalCulling(bool enabled) { portalCulling = enabled; }
bool isPortalCullingEnabled() const { return portalCulling; }
/**
* Get number of groups culled by portals last frame
*/
uint32_t getPortalCulledGroups() const { return lastPortalCulledGroups; }
void setFog(const glm::vec3& color, float start, float end) {
fogColor = color; fogStart = start; fogEnd = end;
}
@ -209,6 +221,22 @@ private:
std::vector<uint16_t> collisionIndices;
};
/**
* Portal data for visibility culling
*/
struct PortalData {
uint16_t startVertex;
uint16_t vertexCount;
glm::vec3 normal;
float distance;
};
struct PortalRef {
uint16_t portalIndex;
uint16_t groupIndex;
int16_t side;
};
/**
* Loaded WMO model data
*/
@ -227,6 +255,13 @@ private:
// Material blend modes (materialId -> blendMode; 1 = alpha-test cutout)
std::vector<uint32_t> materialBlendModes;
// Portal visibility data
std::vector<PortalData> portals;
std::vector<glm::vec3> portalVertices;
std::vector<PortalRef> portalRefs;
// For each group: which portal refs belong to it (start index, count)
std::vector<std::pair<uint16_t, uint16_t>> groupPortalRefs;
uint32_t getTotalTriangles() const {
uint32_t total = 0;
for (const auto& group : groups) {
@ -272,6 +307,34 @@ private:
bool isGroupVisible(const GroupResources& group, const glm::mat4& modelMatrix,
const Camera& camera) const;
/**
* Find which group index contains a position (model space)
* @return Group index or -1 if outside all groups
*/
int findContainingGroup(const ModelData& model, const glm::vec3& localPos) const;
/**
* Get visible groups via portal traversal
* @param model The WMO model data
* @param cameraLocalPos Camera position in model space
* @param frustum Frustum for portal visibility testing
* @param modelMatrix Transform for world-space frustum test
* @param outVisibleGroups Output set of visible group indices
*/
void getVisibleGroupsViaPortals(const ModelData& model,
const glm::vec3& cameraLocalPos,
const Frustum& frustum,
const glm::mat4& modelMatrix,
std::unordered_set<uint32_t>& outVisibleGroups) const;
/**
* Test if a portal polygon is visible from a position through a frustum
*/
bool isPortalVisible(const ModelData& model, uint16_t portalIndex,
const glm::vec3& cameraLocalPos,
const Frustum& frustum,
const glm::mat4& modelMatrix) const;
/**
* Load a texture from path
*/
@ -320,7 +383,9 @@ private:
// Rendering state
bool wireframeMode = false;
bool frustumCulling = true;
bool portalCulling = false; // Disabled by default - needs debugging
uint32_t lastDrawCalls = 0;
mutable uint32_t lastPortalCulledGroups = 0;
// Fog parameters
glm::vec3 fogColor = glm::vec3(0.5f, 0.6f, 0.7f);

View file

@ -477,9 +477,14 @@ void Application::setupUICallbacks() {
gameHandler->setSinglePlayerMode(true);
gameHandler->setSinglePlayerCharListReady();
}
uiManager->getCharacterCreateScreen().reset();
uiManager->getCharacterCreateScreen().initializePreview(assetManager.get());
setState(AppState::CHARACTER_CREATION);
// If characters exist, go to selection; otherwise go to creation
if (gameHandler && !gameHandler->getCharacters().empty()) {
setState(AppState::CHARACTER_SELECTION);
} else {
uiManager->getCharacterCreateScreen().reset();
uiManager->getCharacterCreateScreen().initializePreview(assetManager.get());
setState(AppState::CHARACTER_CREATION);
}
});
// Realm selection callback

View file

@ -356,6 +356,21 @@ WMOModel WMOLoader::load(const std::vector<uint8_t>& wmoData) {
break;
}
case MOPR: {
// Portal references - links groups via portals
uint32_t nRefs = chunkSize / 8; // Each reference is 8 bytes
for (uint32_t i = 0; i < nRefs; i++) {
WMOPortalRef ref;
ref.portalIndex = read<uint16_t>(wmoData, offset);
ref.groupIndex = read<uint16_t>(wmoData, offset);
ref.side = read<int16_t>(wmoData, offset);
ref.padding = read<uint16_t>(wmoData, offset);
model.portalRefs.push_back(ref);
}
core::Logger::getInstance().info("WMO portal refs: ", model.portalRefs.size());
break;
}
default:
// Unknown chunk, skip it
break;

View file

@ -370,6 +370,9 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) {
ImGui::Text("Instances: %u", wmoRenderer->getInstanceCount());
ImGui::Text("Triangles: %u", wmoRenderer->getTotalTriangleCount());
ImGui::Text("Draw Calls: %u", wmoRenderer->getDrawCallCount());
if (wmoRenderer->isPortalCullingEnabled()) {
ImGui::Text("Portal Culled: %u groups", wmoRenderer->getPortalCulledGroups());
}
ImGui::Spacing();
}

View file

@ -296,6 +296,43 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
return false;
}
// Copy portal data for visibility culling
modelData.portalVertices = model.portalVertices;
for (const auto& portal : model.portals) {
PortalData pd;
pd.startVertex = portal.startVertex;
pd.vertexCount = portal.vertexCount;
// Compute portal plane from vertices if we have them
if (portal.vertexCount >= 3 && portal.startVertex + portal.vertexCount <= model.portalVertices.size()) {
glm::vec3 v0 = model.portalVertices[portal.startVertex];
glm::vec3 v1 = model.portalVertices[portal.startVertex + 1];
glm::vec3 v2 = model.portalVertices[portal.startVertex + 2];
pd.normal = glm::normalize(glm::cross(v1 - v0, v2 - v0));
pd.distance = glm::dot(pd.normal, v0);
} else {
pd.normal = glm::vec3(0.0f, 0.0f, 1.0f);
pd.distance = 0.0f;
}
modelData.portals.push_back(pd);
}
for (const auto& ref : model.portalRefs) {
PortalRef pr;
pr.portalIndex = ref.portalIndex;
pr.groupIndex = ref.groupIndex;
pr.side = ref.side;
modelData.portalRefs.push_back(pr);
}
// Build per-group portal ref ranges from WMOGroup data
modelData.groupPortalRefs.resize(model.groups.size(), {0, 0});
for (size_t gi = 0; gi < model.groups.size(); gi++) {
modelData.groupPortalRefs[gi] = {model.groups[gi].portalStart, model.groups[gi].portalCount};
}
if (!modelData.portals.empty()) {
core::Logger::getInstance().info("WMO portals: ", modelData.portals.size(),
" refs: ", modelData.portalRefs.size());
}
loadedModels[id] = std::move(modelData);
core::Logger::getInstance().info("WMO model ", id, " loaded successfully (", loadedGroups, " groups)");
return true;
@ -527,6 +564,8 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm:
Frustum frustum;
frustum.extractFromMatrix(projection * view);
lastPortalCulledGroups = 0;
// Render all instances with instance-level culling
for (const auto& instance : instances) {
// NOTE: Disabled hard instance-distance culling for WMOs.
@ -549,8 +588,26 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm:
const ModelData& model = modelIt->second;
shader->setUniform("uModel", instance.modelMatrix);
// Portal-based visibility culling
std::unordered_set<uint32_t> portalVisibleGroups;
bool usePortalCulling = portalCulling && !model.portals.empty() && !model.portalRefs.empty();
if (usePortalCulling) {
// Transform camera position to model's local space
glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camera.getPosition(), 1.0f);
glm::vec3 cameraLocalPos(localCamPos);
getVisibleGroupsViaPortals(model, cameraLocalPos, frustum, instance.modelMatrix, portalVisibleGroups);
}
// Render all groups using cached world-space bounds
for (size_t gi = 0; gi < model.groups.size(); ++gi) {
// Portal culling check
if (usePortalCulling && portalVisibleGroups.find(static_cast<uint32_t>(gi)) == portalVisibleGroups.end()) {
lastPortalCulledGroups++;
continue;
}
if (frustumCulling && gi < instance.worldGroupBounds.size()) {
const auto& [gMin, gMax] = instance.worldGroupBounds[gi];
if (!frustum.intersectsAABB(gMin, gMax)) {
@ -799,6 +856,111 @@ bool WMORenderer::isGroupVisible(const GroupResources& group, const glm::mat4& m
return behindCount < 8;
}
int WMORenderer::findContainingGroup(const ModelData& model, const glm::vec3& localPos) const {
// Find which group's bounding box contains the position
// Prefer interior groups (smaller volume) when multiple match
int bestGroup = -1;
float bestVolume = std::numeric_limits<float>::max();
for (size_t gi = 0; gi < model.groups.size(); gi++) {
const auto& group = model.groups[gi];
if (localPos.x >= group.boundingBoxMin.x && localPos.x <= group.boundingBoxMax.x &&
localPos.y >= group.boundingBoxMin.y && localPos.y <= group.boundingBoxMax.y &&
localPos.z >= group.boundingBoxMin.z && localPos.z <= group.boundingBoxMax.z) {
glm::vec3 size = group.boundingBoxMax - group.boundingBoxMin;
float volume = size.x * size.y * size.z;
if (volume < bestVolume) {
bestVolume = volume;
bestGroup = static_cast<int>(gi);
}
}
}
return bestGroup;
}
bool WMORenderer::isPortalVisible(const ModelData& model, uint16_t portalIndex,
[[maybe_unused]] const glm::vec3& cameraLocalPos,
const Frustum& frustum,
const glm::mat4& modelMatrix) const {
if (portalIndex >= model.portals.size()) return false;
const auto& portal = model.portals[portalIndex];
if (portal.vertexCount < 3) return false;
if (portal.startVertex + portal.vertexCount > model.portalVertices.size()) return false;
// Get portal polygon center and bounds for frustum test
glm::vec3 center(0.0f);
glm::vec3 pMin = model.portalVertices[portal.startVertex];
glm::vec3 pMax = pMin;
for (uint16_t i = 0; i < portal.vertexCount; i++) {
const auto& v = model.portalVertices[portal.startVertex + i];
center += v;
pMin = glm::min(pMin, v);
pMax = glm::max(pMax, v);
}
center /= static_cast<float>(portal.vertexCount);
// Transform bounds to world space for frustum test
glm::vec4 worldMin = modelMatrix * glm::vec4(pMin, 1.0f);
glm::vec4 worldMax = modelMatrix * glm::vec4(pMax, 1.0f);
// Check if portal AABB intersects frustum (more robust than point test)
return frustum.intersectsAABB(glm::vec3(worldMin), glm::vec3(worldMax));
}
void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model,
const glm::vec3& cameraLocalPos,
const Frustum& frustum,
const glm::mat4& modelMatrix,
std::unordered_set<uint32_t>& outVisibleGroups) const {
// Find camera's containing group
int cameraGroup = findContainingGroup(model, cameraLocalPos);
// If camera is outside all groups, fall back to frustum culling only
if (cameraGroup < 0) {
// Camera outside WMO - mark all groups as potentially visible
// (will still be frustum culled in render)
for (size_t gi = 0; gi < model.groups.size(); gi++) {
outVisibleGroups.insert(static_cast<uint32_t>(gi));
}
return;
}
// BFS through portals from camera's group
std::vector<bool> visited(model.groups.size(), false);
std::vector<uint32_t> queue;
queue.push_back(static_cast<uint32_t>(cameraGroup));
visited[cameraGroup] = true;
outVisibleGroups.insert(static_cast<uint32_t>(cameraGroup));
size_t queueIdx = 0;
while (queueIdx < queue.size()) {
uint32_t currentGroup = queue[queueIdx++];
// Get portal refs for this group
if (currentGroup >= model.groupPortalRefs.size()) continue;
auto [portalStart, portalCount] = model.groupPortalRefs[currentGroup];
for (uint16_t pi = 0; pi < portalCount; pi++) {
uint16_t refIdx = portalStart + pi;
if (refIdx >= model.portalRefs.size()) continue;
const auto& ref = model.portalRefs[refIdx];
uint32_t targetGroup = ref.groupIndex;
if (targetGroup >= model.groups.size()) continue;
if (visited[targetGroup]) continue;
// Check if portal is visible from camera
if (isPortalVisible(model, ref.portalIndex, cameraLocalPos, frustum, modelMatrix)) {
visited[targetGroup] = true;
outVisibleGroups.insert(targetGroup);
queue.push_back(targetGroup);
}
}
}
}
void WMORenderer::WMOInstance::updateModelMatrix() {
modelMatrix = glm::mat4(1.0f);
modelMatrix = glm::translate(modelMatrix, position);