fix: look up the scene at a position as the client does, in constant time

Chunk bounds span (width - 1) * scale; the nearest scene map cell is used and the far edge is kept one cell inside. Chunks are indexed by grid cell when they form a grid. Skipping reads instead of seeking, so a cut-off file can't seek past the end. Terrain mesh indices follow the vertex order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-10-16 11:13:19 -05:00
parent 53d4c1fe2f
commit 200958dd79
5 changed files with 200 additions and 90 deletions

View File

@@ -15,7 +15,7 @@ target_include_directories(dZoneManager PUBLIC "."
"${PROJECT_SOURCE_DIR}/dGame" # Entity.h
"${PROJECT_SOURCE_DIR}/dGame/dEntity" # EntityInfo.h
PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon/dClient" # SceneColors.h
"${PROJECT_SOURCE_DIR}/dCommon/dClient" # SceneColor.h
"${PROJECT_SOURCE_DIR}/dGame/dComponents" #InventoryComponent.h
"${PROJECT_SOURCE_DIR}/dGame/dInventory" #InventoryComponent.h (transitive)
"${PROJECT_SOURCE_DIR}/dGame/dBehaviors" #BehaviorSlot.h

View File

@@ -5,6 +5,7 @@
#include <fstream>
#include <algorithm>
#include <limits>
#include <cmath>
namespace {
constexpr uint32_t kMaxResolution = 4096;
@@ -19,25 +20,39 @@ bool Chunk::IsValidForSceneLookup() const {
&& scaleFactor > 0.0f && width > 1 && height > 1;
}
uint8_t Chunk::GetSceneIDAtGrid(uint32_t i, uint32_t j) const {
const float sceneMapI = (static_cast<float>(i) / static_cast<float>(width - 1)) * static_cast<float>(colorMapResolution - 1);
const float sceneMapJ = (static_cast<float>(j) / static_cast<float>(height - 1)) * static_cast<float>(colorMapResolution - 1);
const uint32_t sceneI = std::min(static_cast<uint32_t>(sceneMapI), colorMapResolution - 1);
const uint32_t sceneJ = std::min(static_cast<uint32_t>(sceneMapJ), colorMapResolution - 1);
const uint32_t sceneIndex = sceneI * colorMapResolution + sceneJ;
uint8_t Chunk::GetSceneIDAtLocal(float localX, float localZ) const {
// The nearest scene map cell
const auto toCell = [this](float cell) {
return static_cast<uint32_t>(std::clamp(static_cast<int32_t>(std::floor(cell + 0.5f)), 0, static_cast<int32_t>(colorMapResolution) - 1));
};
const uint32_t sceneIndex = toCell(sceneCellsPerUnitX * localX) * colorMapResolution + toCell(sceneCellsPerUnitZ * localZ);
if (sceneIndex >= sceneMap.size()) return 0;
return sceneMap[sceneIndex];
}
NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
const float y = (i * width + j < heightMap.size()) ? heightMap[i * width + j] : 0.0f;
return NiPoint3(
(static_cast<float>(i) + (offsetX / scaleFactor)) * scaleFactor,
y,
(static_cast<float>(j) + (offsetZ / scaleFactor)) * scaleFactor
);
uint8_t Chunk::GetSceneIDAtGrid(uint32_t i, uint32_t j) const {
return GetSceneIDAtLocal(static_cast<float>(i) * scaleFactor, static_cast<float>(j) * scaleFactor);
}
NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
const uint32_t heightIndex = i * height + j;
const float y = heightIndex < heightMap.size() ? heightMap[heightIndex] : 0.0f;
return NiPoint3(offsetX + static_cast<float>(i) * scaleFactor, y, offsetZ + static_cast<float>(j) * scaleFactor);
}
/**
* @brief Skip bytes, such as the byte version 30 and older files have in place of a scene map
*/
static bool Skip(std::istream& stream, size_t bytes) {
if (bytes > kMaxBlobBytes) {
LOG("Raw block of %zu bytes exceeds maximum %zu bytes", bytes, kMaxBlobBytes);
return false;
}
// ignore, not seekg: the asset streams don't stop a seek at the end of the data
stream.ignore(static_cast<std::streamsize>(bytes));
return static_cast<size_t>(stream.gcount()) == bytes && !stream.fail();
}
/**
* @brief Read flair attributes from stream
*/
@@ -80,7 +95,7 @@ NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
/**
* @brief Read a chunk from stream
*/
static bool ReadChunk(std::istream& stream, Chunk& chunk, uint16_t version) {
static bool ReadChunkData(std::istream& stream, Chunk& chunk, uint16_t version) {
try {
// Read basic chunk info
BinaryIO::BinaryRead(stream, chunk.id);
@@ -294,8 +309,8 @@ NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
chunk.sceneMap[row * chunk.colorMapResolution + col] = rawSceneMap[row * (chunk.colorMapResolution + 1) + col];
}
}
} else {
stream.seekg(1, std::ios::cur);
} else if (!Skip(stream, 1)) {
return false;
}
if (stream.fail()) {
@@ -353,6 +368,59 @@ NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
}
}
/**
* @brief Read a chunk and work out its scene map cell sizes
*/
static bool ReadChunk(std::istream& stream, Chunk& chunk, uint16_t version) {
if (!ReadChunkData(stream, chunk, version)) return false;
if (chunk.IsValidForSceneLookup()) {
chunk.sceneCellSizeX = static_cast<float>(chunk.width - 1) * chunk.scaleFactor / static_cast<float>(chunk.colorMapResolution);
chunk.sceneCellSizeZ = static_cast<float>(chunk.height - 1) * chunk.scaleFactor / static_cast<float>(chunk.colorMapResolution);
chunk.sceneCellsPerUnitX = 1.0f / chunk.sceneCellSizeX;
chunk.sceneCellsPerUnitZ = 1.0f / chunk.sceneCellSizeZ;
}
return true;
}
/**
* @brief Index the chunks by grid cell when they form a regular grid of equal chunks
*/
static void BuildChunkGrid(Raw& raw) {
raw.chunkGrid.clear();
const auto& first = raw.chunks.front();
const float spanX = first.GetMaxX() - first.offsetX;
const float spanZ = first.GetMaxZ() - first.offsetZ;
if (spanX <= 0.0f || spanZ <= 0.0f) return;
const auto countX = static_cast<uint32_t>(std::lround((raw.maxBoundsX - raw.minBoundsX) / spanX));
const auto countZ = static_cast<uint32_t>(std::lround((raw.maxBoundsZ - raw.minBoundsZ) / spanZ));
if (countX == 0 || countZ == 0 || static_cast<size_t>(countX) * countZ > kMaxChunks) return;
std::vector<int32_t> grid(static_cast<size_t>(countX) * countZ, -1);
for (size_t i = 0; i < raw.chunks.size(); ++i) {
const auto& chunk = raw.chunks[i];
const float cellX = (chunk.offsetX - raw.minBoundsX) / spanX;
const float cellZ = (chunk.offsetZ - raw.minBoundsZ) / spanZ;
const long x = std::lround(cellX);
const long z = std::lround(cellZ);
const bool sameSize = chunk.width == first.width && chunk.height == first.height && chunk.scaleFactor == first.scaleFactor;
if (!sameSize || std::abs(cellX - x) > 0.01f || std::abs(cellZ - z) > 0.01f || x < 0 || z < 0
|| x >= static_cast<long>(countX) || z >= static_cast<long>(countZ)) {
LOG("Raw chunks are not a regular grid (chunk %u); scene lookups check every chunk", chunk.id);
return;
}
auto& cell = grid[static_cast<size_t>(x) * countZ + z];
if (cell == -1) cell = static_cast<int32_t>(i); // the client takes the first chunk that matches
}
raw.chunkGrid = std::move(grid);
raw.chunkGridCountX = countX;
raw.chunkGridCountZ = countZ;
raw.chunkSpanX = spanX;
raw.chunkSpanZ = spanZ;
}
bool ReadRaw(std::istream& stream, Raw& outRaw) {
// Get stream size
stream.seekg(0, std::ios::end);
@@ -404,18 +472,14 @@ NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
outRaw.maxBoundsZ = std::numeric_limits<float>::lowest();
for (const auto& chunk : outRaw.chunks) {
const float chunkMinX = chunk.offsetX;
const float chunkMinZ = chunk.offsetZ;
const float chunkMaxX = chunkMinX + (chunk.width * chunk.scaleFactor);
const float chunkMaxZ = chunkMinZ + (chunk.height * chunk.scaleFactor);
outRaw.minBoundsX = std::min(outRaw.minBoundsX, chunkMinX);
outRaw.minBoundsZ = std::min(outRaw.minBoundsZ, chunkMinZ);
outRaw.maxBoundsX = std::max(outRaw.maxBoundsX, chunkMaxX);
outRaw.maxBoundsZ = std::max(outRaw.maxBoundsZ, chunkMaxZ);
outRaw.minBoundsX = std::min(outRaw.minBoundsX, chunk.offsetX);
outRaw.minBoundsZ = std::min(outRaw.minBoundsZ, chunk.offsetZ);
outRaw.maxBoundsX = std::max(outRaw.maxBoundsX, chunk.GetMaxX());
outRaw.maxBoundsZ = std::max(outRaw.maxBoundsZ, chunk.GetMaxZ());
}
LOG("Raw terrain bounds: X[%.2f, %.2f], Z[%.2f, %.2f]",
LOG_DEBUG("Raw terrain bounds: X[%.2f, %.2f], Z[%.2f, %.2f]",
outRaw.minBoundsX, outRaw.maxBoundsX, outRaw.minBoundsZ, outRaw.maxBoundsZ);
BuildChunkGrid(outRaw);
}
}
return true;
@@ -424,6 +488,54 @@ NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
}
}
// The client's TerrainManager::GetSceneAtPos: clamp to the terrain bounds, keep one scene cell inside the far edge,
// find the chunk containing the position (edges moved in by 0.001) and take its nearest scene map cell
std::optional<uint8_t> Raw::GetSceneIDAt(float x, float z) const {
if (chunks.empty()) return std::nullopt;
constexpr float kEdgeEpsilon = 0.001f;
const float posX = std::clamp(x, minBoundsX, maxBoundsX);
const float posZ = std::clamp(z, minBoundsZ, maxBoundsZ);
const auto sceneInChunk = [&](const Chunk& chunk) -> std::optional<uint8_t> {
if (!chunk.IsValidForSceneLookup()) return std::nullopt;
const float chunkX = std::min(posX, maxBoundsX - chunk.sceneCellSizeX);
const float chunkZ = std::min(posZ, maxBoundsZ - chunk.sceneCellSizeZ);
if (chunk.offsetX - kEdgeEpsilon <= chunkX && chunkX < chunk.GetMaxX() - kEdgeEpsilon &&
chunk.offsetZ - kEdgeEpsilon <= chunkZ && chunkZ < chunk.GetMaxZ() - kEdgeEpsilon) {
return chunk.GetSceneIDAtLocal(chunkX - chunk.offsetX, chunkZ - chunk.offsetZ);
}
return std::nullopt;
};
if (chunkGrid.empty()) {
for (const auto& chunk : chunks) {
if (const auto sceneID = sceneInChunk(chunk)) return sceneID;
}
return std::nullopt;
}
// The grid cell the position falls in, checked with the client's test; for a position on a float edge the
// neighbouring cells are checked too
const auto toCell = [](float pos, float min, float span, uint32_t count) {
const auto cell = static_cast<int32_t>(std::floor((pos + kEdgeEpsilon - min) / span));
return std::clamp(cell, 0, static_cast<int32_t>(count) - 1);
};
const int32_t cellX = toCell(posX, minBoundsX, chunkSpanX, chunkGridCountX);
const int32_t cellZ = toCell(posZ, minBoundsZ, chunkSpanZ, chunkGridCountZ);
for (const int32_t offsetX : { 0, -1, 1 }) {
for (const int32_t offsetZ : { 0, -1, 1 }) {
const int32_t x = cellX + offsetX;
const int32_t z = cellZ + offsetZ;
if (x < 0 || z < 0 || x >= static_cast<int32_t>(chunkGridCountX) || z >= static_cast<int32_t>(chunkGridCountZ)) continue;
const int32_t index = chunkGrid[static_cast<size_t>(x) * chunkGridCountZ + z];
if (index < 0) continue;
if (const auto sceneID = sceneInChunk(chunks[index])) return sceneID;
}
}
return std::nullopt;
}
void GenerateTerrainMesh(const Raw& raw, TerrainMesh& outMesh) {
outMesh.vertices.clear();
outMesh.triangles.clear();
@@ -432,24 +544,22 @@ NiPoint3 Chunk::GridToWorldPos(uint32_t i, uint32_t j) const {
return; // No scene data available
}
LOG("GenerateTerrainMesh: Processing %zu chunks", raw.chunks.size());
LOG_DEBUG("GenerateTerrainMesh: Processing %zu chunks", raw.chunks.size());
uint32_t vertexOffset = 0;
for (const auto& chunk : raw.chunks) {
if (!chunk.IsValidForSceneLookup()) continue;
// One vertex per heightmap point, in [i * height + j] order
for (uint32_t i = 0; i < chunk.width; ++i) {
for (uint32_t j = 0; j < chunk.height; ++j) {
const uint32_t heightIndex = chunk.width * i + j;
if (heightIndex >= chunk.heightMap.size()) continue;
outMesh.vertices.emplace_back(chunk.GridToWorldPos(i, j), chunk.GetSceneIDAtGrid(i, j));
if (i > 0 && j > 0) {
const uint32_t currentVert = vertexOffset + chunk.width * i + j;
const uint32_t currentVert = vertexOffset + chunk.height * i + j;
const uint32_t leftVert = currentVert - 1;
const uint32_t bottomLeftVert = vertexOffset + chunk.width * (i - 1) + j - 1;
const uint32_t bottomVert = vertexOffset + chunk.width * (i - 1) + j;
const uint32_t bottomLeftVert = vertexOffset + chunk.height * (i - 1) + j - 1;
const uint32_t bottomVert = vertexOffset + chunk.height * (i - 1) + j;
// First triangle
outMesh.triangles.push_back(currentVert);

View File

@@ -4,6 +4,7 @@
#define __RAW_H__
#include <cstdint>
#include <optional>
#include <vector>
#include <string>
#include <istream>
@@ -61,7 +62,7 @@ struct TerrainMesh {
/**
* @brief Terrain chunk structure
* Represents a single chunk of terrain with heightmap, textures, and meshes
* Represents a single chunk of terrain with heightmap, textures, flair, scene map and meshes
*/
struct Chunk {
uint32_t id;
@@ -69,40 +70,50 @@ struct Chunk {
uint32_t height;
float offsetX;
float offsetZ;
uint32_t shaderId;
uint32_t shaderId = 0; // version < 32 only
// Texture IDs (4 textures per chunk)
std::vector<uint32_t> textureIds;
// Terrain scale factor
// Terrain scale factor (world units between two heightmap points)
float scaleFactor;
// Heightmap data (width * height floats)
std::vector<float> heightMap;
// Version 32+ fields
// Color map: colorMapResolution^2 RGBA pixels (version < 32: width^2 pixels, resolution width - 1)
uint32_t colorMapResolution = 0;
std::vector<uint8_t> colorMap; // RGBA pixels (colorMap * colorMap * 4)
std::vector<uint8_t> lightMap;
std::vector<uint8_t> colorMap;
std::vector<uint8_t> lightMap; // DDS, version 32+
uint32_t textureMapResolution = 0;
std::vector<uint8_t> textureMap; // (textureMapResolution * textureMapResolution * 4)
uint8_t textureSettings = 0;
std::vector<uint8_t> blendMap;
std::vector<uint8_t> textureMap; // textureMapResolution^2 RGBA pixels
uint8_t textureSettings = 0; // version 32+
std::vector<uint8_t> blendMap; // DDS, version 32+
// Flair data
std::vector<FlairAttributes> flairs;
// Scene map (version 32+)
// The scene map is colorMapResolution * colorMapResolution scene IDs, indexed [x cell * resolution + z cell]
std::vector<uint8_t> sceneMap;
// Mesh data
// Mesh data (version 32+)
uint32_t vertSize = 0;
std::vector<uint16_t> meshVertUsage;
std::vector<uint16_t> meshVertSize;
std::vector<MeshTri> meshTri;
// Scene map cells per world unit, and the size of one cell, along X and Z (set once the chunk is read)
float sceneCellsPerUnitX = 0.0f;
float sceneCellsPerUnitZ = 0.0f;
float sceneCellSizeX = 0.0f;
float sceneCellSizeZ = 0.0f;
bool IsValidForSceneLookup() const;
float GetMaxX() const { return offsetX + static_cast<float>(width - 1) * scaleFactor; }
float GetMaxZ() const { return offsetZ + static_cast<float>(height - 1) * scaleFactor; }
// The scene ID at a position relative to the chunk's offset, as the client looks it up
uint8_t GetSceneIDAtLocal(float localX, float localZ) const;
uint8_t GetSceneIDAtGrid(uint32_t i, uint32_t j) const;
NiPoint3 GridToWorldPos(uint32_t i, uint32_t j) const;
};
@@ -112,8 +123,8 @@ struct Chunk {
* Complete representation of a .raw terrain file
*/
struct Raw {
uint16_t version;
uint8_t dev;
uint16_t version = 0;
uint8_t dev = 0;
uint32_t numChunks = 0;
uint32_t numChunksWidth = 0;
uint32_t numChunksHeight = 0;
@@ -124,6 +135,23 @@ struct Raw {
float minBoundsZ = 0.0f;
float maxBoundsX = 0.0f;
float maxBoundsZ = 0.0f;
// Chunk lookup by position: the chunks of every client terrain form a regular grid, written X-major
// ([x * chunks along Z + z]). chunkGrid holds the index into chunks per grid cell (-1 for none); it is empty when
// the chunks don't form a grid, and lookups then check every chunk.
std::vector<int32_t> chunkGrid;
uint32_t chunkGridCountX = 0;
uint32_t chunkGridCountZ = 0;
float chunkSpanX = 0.0f;
float chunkSpanZ = 0.0f;
/**
* @brief The scene ID at a world position, looked up as the client's TerrainManager::GetSceneAtPos does
* (height is ignored). Constant time on a chunk grid.
*
* @return The scene ID, or nullopt when there is no terrain with a scene map there
*/
std::optional<uint8_t> GetSceneIDAt(float x, float z) const;
};
/**

View File

@@ -12,7 +12,6 @@
#include "CDZoneTableTable.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <set>
#include "eObjectBits.h"
#include "AssetManager.h"
@@ -307,36 +306,8 @@ void dZoneManager::LoadWorldConfig() {
LWOSCENEID dZoneManager::GetSceneIDFromPosition(const NiPoint3& position) const {
if (!m_pZone) return LWOSCENEID_INVALID;
const auto& raw = m_pZone->GetZoneRaw();
// If no chunks, no scene data available
if (raw.chunks.empty()) {
return LWOSCENEID_INVALID;
}
// Convert 3D position to 2D (XZ plane) and clamp to terrain bounds
float posX = std::clamp(position.x, raw.minBoundsX, raw.maxBoundsX);
float posZ = std::clamp(position.z, raw.minBoundsZ, raw.maxBoundsZ);
// Find the chunk containing this position
// Reverse the world position calculation from GenerateTerrainMesh
for (const auto& chunk : raw.chunks) {
if (!chunk.IsValidForSceneLookup()) continue;
const float heightI = posX / chunk.scaleFactor - (chunk.offsetX / chunk.scaleFactor);
const float heightJ = posZ / chunk.scaleFactor - (chunk.offsetZ / chunk.scaleFactor);
if (heightI >= 0.0f && heightI < static_cast<float>(chunk.width) &&
heightJ >= 0.0f && heightJ < static_cast<float>(chunk.height)) {
const uint32_t gridI = std::min(static_cast<uint32_t>(heightI), chunk.width - 1);
const uint32_t gridJ = std::min(static_cast<uint32_t>(heightJ), chunk.height - 1);
return LWOSCENEID(chunk.GetSceneIDAtGrid(gridI, gridJ), 0);
}
}
// Position not found in any chunk
return LWOSCENEID_INVALID;
const auto sceneID = m_pZone->GetZoneRaw().GetSceneIDAt(position.x, position.z);
return sceneID ? LWOSCENEID(*sceneID, 0) : LWOSCENEID_INVALID;
}
void dZoneManager::BuildSceneGraph() {

View File

@@ -77,6 +77,11 @@ public:
*/
std::vector<LWOSCENEID> GetAdjacentScenes(LWOSCENEID sceneID) const;
/**
* @brief (Re)builds the scene graph adjacency list from the loaded zone's scene transitions.
*/
void BuildSceneGraph();
// The world config should not be modified by a caller.
const WorldConfig& GetWorldConfig() {
if (!m_WorldConfig) LoadWorldConfig();
@@ -84,10 +89,6 @@ public:
};
private:
/**
* Builds the scene graph adjacency list from scene transitions
*/
void BuildSceneGraph();
/**
* The starting unique mission ID.
*/