Improve performance and tune ramp/planter collision behavior

This commit is contained in:
Kelsi 2026-02-03 17:21:04 -08:00
parent f43e6bf834
commit f00d13bfc0
12 changed files with 310 additions and 164 deletions

View file

@ -5,6 +5,7 @@
#include <string>
#include <vector>
#include <sys/types.h>
#include <chrono>
namespace wowee {
namespace pipeline { class AssetManager; }
@ -57,6 +58,7 @@ private:
std::string tempFilePath = "/tmp/wowee_footstep.wav";
pid_t playerPid = -1;
std::chrono::steady_clock::time_point lastPlayTime = std::chrono::steady_clock::time_point{};
std::mt19937 rng;
};

View file

@ -94,8 +94,8 @@ private:
static constexpr float PIVOT_HEIGHT = 1.8f; // Pivot at head height
static constexpr float CAM_SPHERE_RADIUS = 0.32f; // Keep camera farther from geometry to avoid clipping-through surfaces
static constexpr float CAM_EPSILON = 0.22f; // Extra wall offset to avoid near-plane clipping artifacts
static constexpr float COLLISION_FOCUS_RADIUS_THIRD_PERSON = 90.0f;
static constexpr float COLLISION_FOCUS_RADIUS_FREE_FLY = 70.0f;
static constexpr float COLLISION_FOCUS_RADIUS_THIRD_PERSON = 42.0f;
static constexpr float COLLISION_FOCUS_RADIUS_FREE_FLY = 34.0f;
static constexpr float MIN_PITCH = -88.0f; // Look almost straight down
static constexpr float MAX_PITCH = 35.0f; // Limited upward look
glm::vec3* followTarget = nullptr;

View file

@ -44,6 +44,7 @@ struct M2ModelGPU {
float boundRadius = 0.0f;
bool collisionSteppedFountain = false;
bool collisionSteppedLowPlatform = false;
bool collisionPlanter = false;
bool collisionNoBlock = false;
std::string name;

View file

@ -2,6 +2,7 @@
#include <GL/glew.h>
#include <glm/glm.hpp>
#include <chrono>
#include <memory>
namespace wowee {
@ -48,6 +49,11 @@ private:
int mapSize = 200;
float viewRadius = 500.0f;
bool enabled = false;
float updateIntervalSec = 0.25f;
float updateDistance = 6.0f;
std::chrono::steady_clock::time_point lastUpdateTime = std::chrono::steady_clock::time_point{};
glm::vec3 lastUpdatePos = glm::vec3(0.0f);
bool hasCachedFrame = false;
};
} // namespace rendering

View file

@ -13,6 +13,7 @@
#include <mutex>
#include <atomic>
#include <queue>
#include <vector>
#include <condition_variable>
#include <glm/glm.hpp>
@ -246,8 +247,8 @@ private:
// Streaming parameters
bool streamingEnabled = true;
int loadRadius = 2; // Load tiles within this radius (5x5 grid for performance)
int unloadRadius = 3; // Unload tiles beyond this radius
int loadRadius = 1; // Load tiles within this radius (3x3 grid for better CPU/GPU perf)
int unloadRadius = 2; // Unload tiles beyond this radius
float updateInterval = 0.1f; // Check streaming every 0.1 seconds
float timeSinceLastUpdate = 0.0f;
@ -257,8 +258,9 @@ private:
static constexpr float TILE_SIZE = 533.33333f; // One tile = 533.33 units
static constexpr float CHUNK_SIZE = 33.33333f; // One chunk = 33.33 units
// Background loading thread
std::thread workerThread;
// Background loading worker pool
std::vector<std::thread> workerThreads;
int workerCount = 0;
std::mutex queueMutex;
std::condition_variable queueCV;
std::queue<TileCoord> loadQueue;

View file

@ -135,6 +135,15 @@ void FootstepManager::reapFinishedProcess() {
}
bool FootstepManager::playRandomStep(FootstepSurface surface, bool sprinting) {
auto now = std::chrono::steady_clock::now();
if (lastPlayTime.time_since_epoch().count() != 0) {
float elapsed = std::chrono::duration<float>(now - lastPlayTime).count();
float minInterval = sprinting ? 0.09f : 0.14f;
if (elapsed < minInterval) {
return false;
}
}
auto& list = surfaces[static_cast<size_t>(surface)].clips;
if (list.empty()) {
list = surfaces[static_cast<size_t>(FootstepSurface::STONE)].clips;
@ -181,6 +190,7 @@ bool FootstepManager::playRandomStep(FootstepSurface surface, bool sprinting) {
_exit(1);
} else if (pid > 0) {
playerPid = pid;
lastPlayTime = now;
return true;
}

View file

@ -523,7 +523,10 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.attachmentLookup = readArray<uint16_t>(m2Data, header.ofsAttachmentLookup, header.nAttachmentLookup);
}
core::Logger::getInstance().debug("M2 model loaded: ", model.name);
static int m2LoadLogBudget = 200;
if (m2LoadLogBudget-- > 0) {
core::Logger::getInstance().debug("M2 model loaded: ", model.name);
}
return model;
}

View file

@ -235,9 +235,9 @@ void CameraController::update(float deltaTime) {
glm::vec3 desiredPos = targetPos;
float moveDist = glm::length(desiredPos - startPos);
// Adaptive CCD: keep per-step movement short, especially on low FPS spikes.
int sweepSteps = std::max(1, std::min(24, static_cast<int>(std::ceil(moveDist / 0.18f))));
int sweepSteps = std::max(1, std::min(14, static_cast<int>(std::ceil(moveDist / 0.24f))));
if (deltaTime > 0.04f) {
sweepSteps = std::min(28, std::max(sweepSteps, static_cast<int>(std::ceil(deltaTime / 0.016f)) * 3));
sweepSteps = std::min(16, std::max(sweepSteps, static_cast<int>(std::ceil(deltaTime / 0.016f)) * 2));
}
glm::vec3 stepPos = startPos;
glm::vec3 stepDelta = (desiredPos - startPos) / static_cast<float>(sweepSteps);
@ -270,42 +270,44 @@ void CameraController::update(float deltaTime) {
// WoW-style slope limiting (50 degrees, with sliding)
// dot(normal, up) >= 0.64 is walkable, otherwise slide
constexpr bool ENABLE_SLOPE_SLIDE = false;
constexpr float MAX_WALK_SLOPE_DOT = 0.6428f; // cos(50°)
constexpr float SAMPLE_DIST = 0.3f; // Distance to sample for normal calculation
{
if (ENABLE_SLOPE_SLIDE) {
glm::vec3 oldPos = *followTarget;
float moveXY = glm::length(glm::vec2(targetPos.x - oldPos.x, targetPos.y - oldPos.y));
if (moveXY >= 0.03f) {
struct GroundSample {
std::optional<float> height;
bool fromM2 = false;
};
// Helper to get ground height at a position and whether M2 provided the top floor.
auto getGroundAt = [&](float x, float y) -> GroundSample {
std::optional<float> terrainH;
std::optional<float> wmoH;
std::optional<float> m2H;
if (terrainManager) {
terrainH = terrainManager->getHeightAt(x, y);
}
if (wmoRenderer) {
wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f);
}
if (m2Renderer) {
m2H = m2Renderer->getFloorHeight(x, y, targetPos.z);
}
auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f);
bool fromM2 = false;
if (m2H && *m2H <= targetPos.z + 1.0f && (!base || *m2H > *base)) {
base = m2H;
fromM2 = true;
}
return GroundSample{base, fromM2};
};
struct GroundSample {
std::optional<float> height;
bool fromM2 = false;
};
// Helper to get ground height at a position and whether M2 provided the top floor.
auto getGroundAt = [&](float x, float y) -> GroundSample {
std::optional<float> terrainH;
std::optional<float> wmoH;
std::optional<float> m2H;
if (terrainManager) {
terrainH = terrainManager->getHeightAt(x, y);
}
if (wmoRenderer) {
wmoH = wmoRenderer->getFloorHeight(x, y, targetPos.z + 5.0f);
}
if (m2Renderer) {
m2H = m2Renderer->getFloorHeight(x, y, targetPos.z);
}
auto base = selectReachableFloor(terrainH, wmoH, targetPos.z, 1.0f);
bool fromM2 = false;
if (m2H && *m2H <= targetPos.z + 1.0f && (!base || *m2H > *base)) {
base = m2H;
fromM2 = true;
}
return GroundSample{base, fromM2};
};
// Get ground height at target position
auto center = getGroundAt(targetPos.x, targetPos.y);
bool skipSlopeCheck = center.height && center.fromM2;
if (center.height && !skipSlopeCheck) {
// Get ground height at target position
auto center = getGroundAt(targetPos.x, targetPos.y);
bool skipSlopeCheck = center.height && center.fromM2;
if (center.height && !skipSlopeCheck) {
// Calculate ground normal using height samples
auto hPosX = getGroundAt(targetPos.x + SAMPLE_DIST, targetPos.y);
@ -361,6 +363,7 @@ void CameraController::update(float deltaTime) {
}
}
}
}
}
}
@ -390,7 +393,7 @@ void CameraController::update(float deltaTime) {
std::optional<float> groundH;
constexpr float FOOTPRINT = 0.28f;
const glm::vec2 offsets[] = {
{0.0f, 0.0f}, {FOOTPRINT, 0.0f}, {-FOOTPRINT, 0.0f}, {0.0f, FOOTPRINT}, {0.0f, -FOOTPRINT}
{0.0f, 0.0f}, {FOOTPRINT, 0.0f}, {0.0f, FOOTPRINT}
};
for (const auto& o : offsets) {
auto h = sampleGround(targetPos.x + o.x, targetPos.y + o.y);
@ -603,9 +606,9 @@ void CameraController::update(float deltaTime) {
glm::vec3 startFeet = camera->getPosition() - glm::vec3(0, 0, eyeHeight);
glm::vec3 desiredFeet = newPos - glm::vec3(0, 0, eyeHeight);
float moveDist = glm::length(desiredFeet - startFeet);
int sweepSteps = std::max(1, std::min(24, static_cast<int>(std::ceil(moveDist / 0.18f))));
int sweepSteps = std::max(1, std::min(14, static_cast<int>(std::ceil(moveDist / 0.24f))));
if (deltaTime > 0.04f) {
sweepSteps = std::min(28, std::max(sweepSteps, static_cast<int>(std::ceil(deltaTime / 0.016f)) * 3));
sweepSteps = std::min(16, std::max(sweepSteps, static_cast<int>(std::ceil(deltaTime / 0.016f)) * 2));
}
glm::vec3 stepPos = startFeet;
glm::vec3 stepDelta = (desiredFeet - startFeet) / static_cast<float>(sweepSteps);

View file

@ -70,9 +70,9 @@ float getEffectiveCollisionTopLocal(const M2ModelGPU& model,
// use edge distance (not radial) so corner blocks don't become too low and
// clip-through at diagonals.
float edge = std::max(std::abs(nx), std::abs(ny));
if (edge > 0.92f) return localMin.z + h * 0.10f;
if (edge > 0.72f) return localMin.z + h * 0.46f;
return localMin.z + h * 0.74f;
if (edge > 0.92f) return localMin.z + h * 0.06f;
if (edge > 0.72f) return localMin.z + h * 0.30f;
return localMin.z + h * 0.62f;
}
bool segmentIntersectsAABB(const glm::vec3& from, const glm::vec3& to,
@ -351,6 +351,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
(likelyCurbName && (lowPlatformShape || lowWideShape)));
bool isPlanter = (lowerName.find("planter") != std::string::npos);
gpuModel.collisionPlanter = isPlanter;
bool foliageName =
(lowerName.find("bush") != std::string::npos) ||
(lowerName.find("grass") != std::string::npos) ||
@ -371,7 +372,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
bool softTree = treeLike && !hardTreePart && (canopyLike || vert > horiz * 1.35f);
bool smallSoftShape = (horiz < 2.2f && vert < 2.4f);
bool mediumFoliageShape = (horiz < 4.5f && vert < 4.5f);
bool forceSolidCurb = gpuModel.collisionSteppedLowPlatform || knownStormwindPlanter || likelyCurbName;
bool forceSolidCurb = gpuModel.collisionSteppedLowPlatform || knownStormwindPlanter || likelyCurbName || gpuModel.collisionPlanter;
gpuModel.collisionNoBlock = ((((foliageName && smallSoftShape) || (foliageName && mediumFoliageShape)) || softTree) &&
!forceSolidCurb);
}
@ -593,7 +594,7 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
lastDrawCallCount = 0;
// Distance-based culling threshold for M2 models
const float maxRenderDistance = 400.0f; // Balance between performance and visibility
const float maxRenderDistance = 180.0f; // Aggressive culling for city performance
const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance;
const glm::vec3 camPos = camera.getPosition();
@ -609,7 +610,12 @@ void M2Renderer::render(const Camera& camera, const glm::mat4& view, const glm::
float distSq = glm::dot(toCam, toCam);
float worldRadius = model.boundRadius * instance.scale;
// Cull small objects (radius < 20) at distance, keep larger objects visible longer
float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, worldRadius / 10.0f);
float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, worldRadius / 12.0f);
if (worldRadius < 0.8f) {
effectiveMaxDistSq = std::min(effectiveMaxDistSq, 65.0f * 65.0f);
} else if (worldRadius < 1.5f) {
effectiveMaxDistSq = std::min(effectiveMaxDistSq, 95.0f * 95.0f);
}
if (distSq > effectiveMaxDistSq) {
continue;
}
@ -886,7 +892,7 @@ std::optional<float> M2Renderer::getFloorHeight(float glX, float glY, float glZ)
glm::vec3 worldTop = glm::vec3(instance.modelMatrix * glm::vec4(localTop, 1.0f));
// Reachability filter: allow a bit more climb for stepped low platforms.
float maxStepUp = model.collisionSteppedLowPlatform ? 1.8f : 1.0f;
float maxStepUp = model.collisionSteppedLowPlatform ? (model.collisionPlanter ? 2.2f : 1.8f) : 1.0f;
if (worldTop.z > glZ + maxStepUp) continue;
if (!bestFloor || worldTop.z > *bestFloor) {
@ -948,12 +954,12 @@ bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to,
// Swept hard clamp for taller blockers only.
// Low/stepable objects should be climbable and not "shove" the player off.
float maxStepUp = model.collisionSteppedLowPlatform ? 2.0f : 1.20f;
float maxStepUp = model.collisionSteppedLowPlatform ? (model.collisionPlanter ? 2.8f : 2.4f) : 1.20f;
bool stepableLowObject = (effectiveTop <= localFrom.z + maxStepUp);
bool climbingAttempt = (localPos.z > localFrom.z + 0.18f);
bool nearTop = (localFrom.z >= effectiveTop - 0.30f);
bool climbingTowardTop = climbingAttempt && (localFrom.z + 0.35f >= effectiveTop);
bool forceHardLateral = model.collisionSteppedLowPlatform && !nearTop && !climbingTowardTop;
bool climbingTowardTop = climbingAttempt && (localFrom.z + (model.collisionPlanter ? 0.95f : 0.60f) >= effectiveTop);
bool forceHardLateral = model.collisionSteppedLowPlatform && !model.collisionPlanter && !nearTop && !climbingTowardTop;
if (!stepableLowObject || forceHardLateral) {
float tEnter = 0.0f;
glm::vec3 sweepMax = localMax;
@ -988,7 +994,11 @@ bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to,
// Gentle fallback push for overlapping cases.
float pushAmount;
if (model.collisionSteppedLowPlatform) {
pushAmount = std::clamp(minPush * 0.18f, 0.006f, 0.020f);
if (model.collisionPlanter && stepableLowObject) {
pushAmount = std::clamp(minPush * 0.06f, 0.001f, 0.006f);
} else {
pushAmount = std::clamp(minPush * 0.12f, 0.003f, 0.012f);
}
} else if (stepableLowObject) {
pushAmount = std::clamp(minPush * 0.12f, 0.002f, 0.015f);
} else {

View file

@ -140,8 +140,22 @@ void Minimap::shutdown() {
void Minimap::render(const Camera& playerCamera, int screenWidth, int screenHeight) {
if (!enabled || !terrainRenderer || !fbo) return;
// 1. Render terrain from top-down into FBO
renderTerrainToFBO(playerCamera);
const auto now = std::chrono::steady_clock::now();
glm::vec3 playerPos = playerCamera.getPosition();
bool needsRefresh = !hasCachedFrame;
if (!needsRefresh) {
float moved = glm::length(glm::vec2(playerPos.x - lastUpdatePos.x, playerPos.y - lastUpdatePos.y));
float elapsed = std::chrono::duration<float>(now - lastUpdateTime).count();
needsRefresh = (moved >= updateDistance) || (elapsed >= updateIntervalSec);
}
// 1. Render terrain from top-down into FBO (throttled)
if (needsRefresh) {
renderTerrainToFBO(playerCamera);
lastUpdateTime = now;
lastUpdatePos = playerPos;
hasCachedFrame = true;
}
// 2. Draw the minimap quad on screen
renderQuad(screenWidth, screenHeight);

View file

@ -89,9 +89,12 @@ TerrainManager::~TerrainManager() {
if (workerRunning.load()) {
workerRunning.store(false);
queueCV.notify_all();
if (workerThread.joinable()) {
workerThread.join();
for (auto& t : workerThreads) {
if (t.joinable()) {
t.join();
}
}
workerThreads.clear();
}
}
@ -109,14 +112,20 @@ bool TerrainManager::initialize(pipeline::AssetManager* assets, TerrainRenderer*
return false;
}
// Start background worker thread
// Start background worker pool
workerRunning.store(true);
workerThread = std::thread(&TerrainManager::workerLoop, this);
unsigned hc = std::thread::hardware_concurrency();
workerCount = static_cast<int>(hc > 0 ? std::min(4u, std::max(2u, hc - 1)) : 2u);
workerThreads.reserve(workerCount);
for (int i = 0; i < workerCount; i++) {
workerThreads.emplace_back(&TerrainManager::workerLoop, this);
}
LOG_INFO("Terrain manager initialized (async loading enabled)");
LOG_INFO(" Map: ", mapName);
LOG_INFO(" Load radius: ", loadRadius, " tiles");
LOG_INFO(" Unload radius: ", unloadRadius, " tiles");
LOG_INFO(" Workers: ", workerCount);
return true;
}
@ -152,7 +161,7 @@ void TerrainManager::update(const Camera& camera, float deltaTime) {
// Stream tiles if we've moved significantly or initial load
if (newTile.x != lastStreamTile.x || newTile.y != lastStreamTile.y) {
LOG_INFO("Streaming: cam=(", camPos.x, ",", camPos.y, ",", camPos.z,
LOG_DEBUG("Streaming: cam=(", camPos.x, ",", camPos.y, ",", camPos.z,
") tile=[", newTile.x, ",", newTile.y,
"] loaded=", loadedTiles.size());
streamTiles();
@ -189,7 +198,7 @@ bool TerrainManager::loadTile(int x, int y) {
std::unique_ptr<PendingTile> TerrainManager::prepareTile(int x, int y) {
TileCoord coord = {x, y};
LOG_INFO("Preparing tile [", x, ",", y, "] (CPU work)");
LOG_DEBUG("Preparing tile [", x, ",", y, "] (CPU work)");
// Load ADT file
std::string adtPath = getADTPath(coord);
@ -445,7 +454,7 @@ std::unique_ptr<PendingTile> TerrainManager::prepareTile(int x, int y) {
}
}
LOG_INFO("Prepared tile [", x, ",", y, "]: ",
LOG_DEBUG("Prepared tile [", x, ",", y, "]: ",
pending->m2Models.size(), " M2 models, ",
pending->m2Placements.size(), " M2 placements, ",
pending->wmoModels.size(), " WMOs, ",
@ -459,7 +468,7 @@ void TerrainManager::finalizeTile(std::unique_ptr<PendingTile> pending) {
int y = pending->coord.y;
TileCoord coord = pending->coord;
LOG_INFO("Finalizing tile [", x, ",", y, "] (GPU upload)");
LOG_DEBUG("Finalizing tile [", x, ",", y, "] (GPU upload)");
// Check if tile was already loaded (race condition guard) or failed
if (loadedTiles.find(coord) != loadedTiles.end()) {
@ -517,7 +526,7 @@ void TerrainManager::finalizeTile(std::unique_ptr<PendingTile> pending) {
}
}
LOG_INFO(" Loaded doodads for tile [", x, ",", y, "]: ",
LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ",
loadedDoodads, " instances (", uploadedModelIds.size(), " new models, ",
skippedDedup, " dedup skipped)");
}
@ -558,7 +567,7 @@ void TerrainManager::finalizeTile(std::unique_ptr<PendingTile> pending) {
}
}
if (loadedLiquids > 0) {
LOG_INFO(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids);
LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids);
}
// Upload WMO doodad M2 models
@ -572,7 +581,7 @@ void TerrainManager::finalizeTile(std::unique_ptr<PendingTile> pending) {
}
if (loadedWMOs > 0) {
LOG_INFO(" Loaded WMOs for tile [", x, ",", y, "]: ", loadedWMOs);
LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", loadedWMOs);
}
}
@ -591,7 +600,7 @@ void TerrainManager::finalizeTile(std::unique_ptr<PendingTile> pending) {
loadedTiles[coord] = std::move(tile);
LOG_INFO(" Finalized tile [", x, ",", y, "]");
LOG_DEBUG(" Finalized tile [", x, ",", y, "]");
}
void TerrainManager::workerLoop() {
@ -728,9 +737,12 @@ void TerrainManager::unloadAll() {
if (workerRunning.load()) {
workerRunning.store(false);
queueCV.notify_all();
if (workerThread.joinable()) {
workerThread.join();
for (auto& t : workerThreads) {
if (t.joinable()) {
t.join();
}
}
workerThreads.clear();
}
// Clear queues
@ -809,59 +821,88 @@ std::optional<float> TerrainManager::getHeightAt(float glX, float glY) const {
const float unitSize = CHUNK_SIZE / 8.0f;
// Query coordinates are the same as what we pass (terrain uses identity model matrix)
float queryX = glX;
float queryY = glY;
auto sampleTileHeight = [&](const TerrainTile* tile) -> std::optional<float> {
if (!tile || !tile->loaded) return std::nullopt;
for (const auto& [coord, tile] : loadedTiles) {
if (!tile || !tile->loaded) continue;
auto sampleChunk = [&](int cx, int cy) -> std::optional<float> {
if (cx < 0 || cx >= 16 || cy < 0 || cy >= 16) return std::nullopt;
const auto& chunk = tile->terrain.getChunk(cx, cy);
if (!chunk.hasHeightMap()) return std::nullopt;
for (int cy = 0; cy < 16; cy++) {
for (int cx = 0; cx < 16; cx++) {
const auto& chunk = tile->terrain.getChunk(cx, cy);
if (!chunk.hasHeightMap()) continue;
float chunkMaxX = chunk.position[0];
float chunkMinX = chunk.position[0] - 8.0f * unitSize;
float chunkMaxY = chunk.position[1];
float chunkMinY = chunk.position[1] - 8.0f * unitSize;
float chunkMaxX = chunk.position[0];
float chunkMinX = chunk.position[0] - 8.0f * unitSize;
float chunkMaxY = chunk.position[1];
float chunkMinY = chunk.position[1] - 8.0f * unitSize;
if (glX < chunkMinX || glX > chunkMaxX ||
glY < chunkMinY || glY > chunkMaxY) {
return std::nullopt;
}
if (queryX < chunkMinX || queryX > chunkMaxX ||
queryY < chunkMinY || queryY > chunkMaxY) {
continue;
}
// Fractional position within chunk (0-8 range)
float fracY = (chunk.position[0] - glX) / unitSize; // maps to offsetY
float fracX = (chunk.position[1] - glY) / unitSize; // maps to offsetX
// Fractional position within chunk (0-8 range)
float fracY = (chunk.position[0] - glX) / unitSize; // maps to offsetY
float fracX = (chunk.position[1] - glY) / unitSize; // maps to offsetX
fracX = glm::clamp(fracX, 0.0f, 8.0f);
fracY = glm::clamp(fracY, 0.0f, 8.0f);
fracX = glm::clamp(fracX, 0.0f, 8.0f);
fracY = glm::clamp(fracY, 0.0f, 8.0f);
// Bilinear interpolation on 9x9 outer grid
int gx0 = static_cast<int>(std::floor(fracX));
int gy0 = static_cast<int>(std::floor(fracY));
int gx1 = std::min(gx0 + 1, 8);
int gy1 = std::min(gy0 + 1, 8);
// Bilinear interpolation on 9x9 outer grid
int gx0 = static_cast<int>(std::floor(fracX));
int gy0 = static_cast<int>(std::floor(fracY));
int gx1 = std::min(gx0 + 1, 8);
int gy1 = std::min(gy0 + 1, 8);
float tx = fracX - gx0;
float ty = fracY - gy0;
float tx = fracX - gx0;
float ty = fracY - gy0;
float h00 = chunk.heightMap.heights[gy0 * 17 + gx0];
float h10 = chunk.heightMap.heights[gy0 * 17 + gx1];
float h01 = chunk.heightMap.heights[gy1 * 17 + gx0];
float h11 = chunk.heightMap.heights[gy1 * 17 + gx1];
// Outer vertex heights from the 9x17 layout
// Outer vertex (gx, gy) is at index: gy * 17 + gx
float h00 = chunk.heightMap.heights[gy0 * 17 + gx0];
float h10 = chunk.heightMap.heights[gy0 * 17 + gx1];
float h01 = chunk.heightMap.heights[gy1 * 17 + gx0];
float h11 = chunk.heightMap.heights[gy1 * 17 + gx1];
float h = h00 * (1 - tx) * (1 - ty) +
h10 * tx * (1 - ty) +
h01 * (1 - tx) * ty +
h11 * tx * ty;
float h = h00 * (1 - tx) * (1 - ty) +
h10 * tx * (1 - ty) +
h01 * (1 - tx) * ty +
h11 * tx * ty;
return chunk.position[2] + h;
};
return chunk.position[2] + h;
// Fast path: infer likely chunk index and probe 3x3 neighborhood.
int guessCy = glm::clamp(static_cast<int>(std::floor((tile->maxX - glX) / CHUNK_SIZE)), 0, 15);
int guessCx = glm::clamp(static_cast<int>(std::floor((tile->maxY - glY) / CHUNK_SIZE)), 0, 15);
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
auto h = sampleChunk(guessCx + dx, guessCy + dy);
if (h) return h;
}
}
// Fallback full scan for robustness at seams/unusual coords.
for (int cy = 0; cy < 16; cy++) {
for (int cx = 0; cx < 16; cx++) {
auto h = sampleChunk(cx, cy);
if (h) {
return h;
}
}
}
return std::nullopt;
};
// Fast path: sample the expected containing tile first.
TileCoord tc = worldToTile(glX, glY);
auto it = loadedTiles.find(tc);
if (it != loadedTiles.end()) {
auto h = sampleTileHeight(it->second.get());
if (h) return h;
}
// Fallback: check all loaded tiles (handles seam/edge coordinate ambiguity).
for (const auto& [coord, tile] : loadedTiles) {
if (coord == tc) continue;
auto h = sampleTileHeight(tile.get());
if (h) return h;
}
return std::nullopt;
@ -870,61 +911,92 @@ std::optional<float> TerrainManager::getHeightAt(float glX, float glY) const {
std::optional<std::string> TerrainManager::getDominantTextureAt(float glX, float glY) const {
const float unitSize = CHUNK_SIZE / 8.0f;
std::vector<uint8_t> alphaScratch;
auto sampleTileTexture = [&](const TerrainTile* tile) -> std::optional<std::string> {
if (!tile || !tile->loaded) return std::nullopt;
for (const auto& [coord, tile] : loadedTiles) {
(void)coord;
if (!tile || !tile->loaded) continue;
auto sampleChunkTexture = [&](int cx, int cy) -> std::optional<std::string> {
if (cx < 0 || cx >= 16 || cy < 0 || cy >= 16) return std::nullopt;
const auto& chunk = tile->terrain.getChunk(cx, cy);
if (!chunk.hasHeightMap() || chunk.layers.empty()) return std::nullopt;
float chunkMaxX = chunk.position[0];
float chunkMinX = chunk.position[0] - 8.0f * unitSize;
float chunkMaxY = chunk.position[1];
float chunkMinY = chunk.position[1] - 8.0f * unitSize;
if (glX < chunkMinX || glX > chunkMaxX || glY < chunkMinY || glY > chunkMaxY) {
return std::nullopt;
}
float fracY = (chunk.position[0] - glX) / unitSize;
float fracX = (chunk.position[1] - glY) / unitSize;
fracX = glm::clamp(fracX, 0.0f, 8.0f);
fracY = glm::clamp(fracY, 0.0f, 8.0f);
int alphaX = glm::clamp(static_cast<int>((fracX / 8.0f) * 63.0f), 0, 63);
int alphaY = glm::clamp(static_cast<int>((fracY / 8.0f) * 63.0f), 0, 63);
int alphaIndex = alphaY * 64 + alphaX;
std::vector<int> weights(chunk.layers.size(), 0);
int accum = 0;
for (size_t layerIdx = 1; layerIdx < chunk.layers.size(); layerIdx++) {
int alpha = 0;
if (decodeLayerAlpha(chunk, layerIdx, alphaScratch) && alphaIndex < static_cast<int>(alphaScratch.size())) {
alpha = alphaScratch[alphaIndex];
}
weights[layerIdx] = alpha;
accum += alpha;
}
weights[0] = glm::clamp(255 - accum, 0, 255);
size_t bestLayer = 0;
int bestWeight = weights[0];
for (size_t i = 1; i < weights.size(); i++) {
if (weights[i] > bestWeight) {
bestWeight = weights[i];
bestLayer = i;
}
}
uint32_t texId = chunk.layers[bestLayer].textureId;
if (texId < tile->terrain.textures.size()) {
return tile->terrain.textures[texId];
}
return std::nullopt;
};
int guessCy = glm::clamp(static_cast<int>(std::floor((tile->maxX - glX) / CHUNK_SIZE)), 0, 15);
int guessCx = glm::clamp(static_cast<int>(std::floor((tile->maxY - glY) / CHUNK_SIZE)), 0, 15);
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
auto tex = sampleChunkTexture(guessCx + dx, guessCy + dy);
if (tex) return tex;
}
}
for (int cy = 0; cy < 16; cy++) {
for (int cx = 0; cx < 16; cx++) {
const auto& chunk = tile->terrain.getChunk(cx, cy);
if (!chunk.hasHeightMap() || chunk.layers.empty()) continue;
float chunkMaxX = chunk.position[0];
float chunkMinX = chunk.position[0] - 8.0f * unitSize;
float chunkMaxY = chunk.position[1];
float chunkMinY = chunk.position[1] - 8.0f * unitSize;
if (glX < chunkMinX || glX > chunkMaxX || glY < chunkMinY || glY > chunkMaxY) {
continue;
auto tex = sampleChunkTexture(cx, cy);
if (tex) {
return tex;
}
float fracY = (chunk.position[0] - glX) / unitSize;
float fracX = (chunk.position[1] - glY) / unitSize;
fracX = glm::clamp(fracX, 0.0f, 8.0f);
fracY = glm::clamp(fracY, 0.0f, 8.0f);
int alphaX = glm::clamp(static_cast<int>((fracX / 8.0f) * 63.0f), 0, 63);
int alphaY = glm::clamp(static_cast<int>((fracY / 8.0f) * 63.0f), 0, 63);
int alphaIndex = alphaY * 64 + alphaX;
std::vector<int> weights(chunk.layers.size(), 0);
int accum = 0;
for (size_t layerIdx = 1; layerIdx < chunk.layers.size(); layerIdx++) {
int alpha = 0;
if (decodeLayerAlpha(chunk, layerIdx, alphaScratch) && alphaIndex < static_cast<int>(alphaScratch.size())) {
alpha = alphaScratch[alphaIndex];
}
weights[layerIdx] = alpha;
accum += alpha;
}
weights[0] = glm::clamp(255 - accum, 0, 255);
size_t bestLayer = 0;
int bestWeight = weights[0];
for (size_t i = 1; i < weights.size(); i++) {
if (weights[i] > bestWeight) {
bestWeight = weights[i];
bestLayer = i;
}
}
uint32_t texId = chunk.layers[bestLayer].textureId;
if (texId < tile->terrain.textures.size()) {
return tile->terrain.textures[texId];
}
return std::nullopt;
}
}
return std::nullopt;
};
// Fast path: check expected containing tile first.
TileCoord tc = worldToTile(glX, glY);
auto it = loadedTiles.find(tc);
if (it != loadedTiles.end()) {
auto tex = sampleTileTexture(it->second.get());
if (tex) return tex;
}
// Fallback: seam/edge case.
for (const auto& [coord, tile] : loadedTiles) {
if (coord == tc) continue;
auto tex = sampleTileTexture(tile.get());
if (tex) return tex;
}
return std::nullopt;
@ -958,8 +1030,8 @@ void TerrainManager::streamTiles() {
}
}
// Notify worker thread that there's work
queueCV.notify_one();
// Notify workers that there's work
queueCV.notify_all();
// Unload tiles beyond unload radius (well past the camera far clip)
std::vector<TileCoord> tilesToUnload;

View file

@ -460,7 +460,7 @@ void WMORenderer::render(const Camera& camera, const glm::mat4& view, const glm:
// Render all instances with instance-level culling
const glm::vec3 camPos = camera.getPosition();
const float maxRenderDistance = 500.0f; // Reduced for performance
const float maxRenderDistance = 320.0f; // More aggressive culling for city performance
const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance;
for (const auto& instance : instances) {
@ -1094,7 +1094,7 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
if (normalLen < 0.001f) continue;
normal /= normalLen;
// Skip near-horizontal triangles (floors/ceilings), keep sloped tunnel walls.
// Skip near-horizontal triangles (floors/ceilings).
if (std::abs(normal.z) > 0.85f) continue;
// Get triangle Z range
@ -1104,7 +1104,30 @@ bool WMORenderer::checkWallCollision(const glm::vec3& from, const glm::vec3& to,
// Only collide with walls in player's vertical range
if (triMaxZ < localFeetZ + 0.3f) continue;
if (triMinZ > localFeetZ + PLAYER_HEIGHT) continue;
if (triMaxZ <= localFeetZ + MAX_STEP_HEIGHT) continue; // Treat as step-up, not hard wall
// Lower parts of ramps should be stepable from the side.
// Allow a larger step-up budget for ramp-like triangles.
// Allow running off/onto lower ramp side geometry without invisible wall blocks.
if (normal.z > 0.20f && triMaxZ <= localFeetZ + 1.60f) continue;
// Ignore short near-vertical side strips around ramps/edges.
// These commonly act like invisible side guard rails.
float triHeight = triMaxZ - triMinZ;
if (std::abs(normal.z) < 0.25f &&
triHeight < 1.8f &&
triMaxZ <= localFeetZ + 1.4f) {
continue;
}
// Let players run off ramp sides: ignore lower side-wall strips
// that sit around foot height and are not true tall building walls.
if (std::abs(normal.z) < 0.45f &&
triMinZ <= localFeetZ + 0.20f &&
triHeight < 4.0f &&
triMaxZ <= localFeetZ + 4.0f) {
continue;
}
float stepHeightLimit = MAX_STEP_HEIGHT;
if (triMaxZ <= localFeetZ + stepHeightLimit) continue; // Treat as step-up, not hard wall
// Swept test: prevent tunneling when crossing a wall between frames.
float fromDist = glm::dot(localFrom - v0, normal);