From 94ee231b12978e46b4fac5169182b4e93bb12e7f Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 23 Jul 2026 20:17:19 -0700 Subject: [PATCH] fix: align shared terrain tile edges Derive terrain vertex XY from one tile-relative grid expression instead of independently subtracting chunk and vertex offsets. Adjacent tiles now calculate their common edge with identical float values, preventing hairline T-junction seams far from the map origin. --- CHANGELOG.md | 1 + src/pipeline/terrain_mesh.cpp | 32 ++++++++++++++++++++------------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8faf0f..fe8a09f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [v2.0.29-preview] — 2026-07-23 ### World +- **Hairline seams between terrain tiles are gone.** Each tile's edge vertices were built by subtracting the chunk and per-vertex steps from the tile corner, so a tile's far edge (`…×TILE − TILE`) and its neighbour's near edge (`(…−1)×TILE`) — mathematically the same point — rounded to slightly different float32 values. The sub-yard gap opened T-junction cracks that showed as thin lines, worst far from the map origin (across Kalimdor). Vertex XY is now a single multiply from the tile index, `TILE_SIZE × (32 − tile − step/128)`, so the shared edge is bit-identical on both sides and the tiles meet exactly — no mesh-overlap or scale hacks - **Game objects went missing after leaving an area and coming back, for the rest of the session.** Walking away dropped every instance of a mailbox or chest model, and the 60-second unused-model reaper then evicted the model itself; the reload on return did not reliably produce a drawn object. Game object models are now pinned in the renderer, so the reap/reload cycle never happens for them. They are a small bounded set — one per display ID actually encountered — and ambient doodads are still reaped normally - Game objects are exempt from the adaptive doodad render distance. That distance collapses to its densest-scene value in any populated area, which in a city means roughly 200 units, so mailboxes and chests vanished well inside the range the server still considered them visible. They now hold a 600-unit floor; frustum and occlusion culling are unaffected - GPU cull results are matched back to instances by ID rather than array index. The visibility buffer is read a full frame-slot cycle after it is written, and instances are appended and swap-removed in between, so a respawned object landing at the volatile tail of the array could inherit the verdict of whatever transient object held that slot two frames earlier diff --git a/src/pipeline/terrain_mesh.cpp b/src/pipeline/terrain_mesh.cpp index 11bc90a2..43dd8ab2 100644 --- a/src/pipeline/terrain_mesh.cpp +++ b/src/pipeline/terrain_mesh.cpp @@ -189,15 +189,20 @@ std::vector TerrainMeshGenerator::generateVertices(const MapChunk const HeightMap& heightMap = chunk.heightMap; - // WoW terrain uses 145 heights stored in a 9x17 row-major grid layout - const float unitSize = CHUNK_SIZE / 8.0f; // 33.333/8 units per vertex step - - // Compute render-space base from tile/chunk indices (same formula as generateChunkMesh). - const float tileNW_renderX = (32.0f - static_cast(tileY)) * core::coords::TILE_SIZE; - const float tileNW_renderY = (32.0f - static_cast(tileX)) * core::coords::TILE_SIZE; - float chunkBaseX = tileNW_renderX - static_cast(chunkY) * CHUNK_SIZE; // iy controls renderX (east-west) - float chunkBaseY = tileNW_renderY - static_cast(chunkX) * CHUNK_SIZE; // ix controls renderY (north-south) - float chunkBaseZ = chunk.position[2]; // height base (wowZ) from MCNK offset 112 + // WoW terrain uses 145 heights stored in a 9x17 row-major grid layout. + // + // Vertex XY is derived as a SINGLE multiply from the tile index: + // pos = TILE_SIZE * (32 - tile - gridStep/128) + // where gridStep = chunk*8 + vertexOffset runs 0..128 across the 16 chunks of a + // tile. The previous form subtracted the chunk base and the per-vertex step + // separately, so a tile's far edge (…*C - C) and the neighbouring tile's near edge + // ((…-1)*C) rounded to slightly different float32 values — a sub-yard gap that + // opened hairline "blue" T-junction cracks between tiles, worst far from the map + // origin (across Kalimdor). Collapsing both to one multiply makes the shared edge + // bit-identical on either side, closing the seam without the overlap hacks. + const float TS = core::coords::TILE_SIZE; + constexpr float kStepsPerTile = 128.0f; // 16 chunks * 8 vertex steps + const float chunkBaseZ = chunk.position[2]; // height base (wowZ) from MCNK offset 112 for (int index = 0; index < 145; index++) { int y = index / 17; // Row (0-8) @@ -222,9 +227,12 @@ std::vector TerrainMeshGenerator::generateVertices(const MapChunk // vertex would propagate into normal computations and crash culling. float h = heightMap.heights[index]; if (!std::isfinite(h)) h = 0.0f; - vertex.position[0] = chunkBaseX - (offsetY * unitSize); // renderX (row = west→east) - vertex.position[1] = chunkBaseY - (offsetX * unitSize); // renderY (col = north→south) - vertex.position[2] = chunkBaseZ + h; // renderZ + // Fractional grid position within the tile along each render axis (0..128). + const float gridX = static_cast(chunkY) * 8.0f + offsetY; // row → renderX (west→east) + const float gridY = static_cast(chunkX) * 8.0f + offsetX; // col → renderY (north→south) + vertex.position[0] = (32.0f - static_cast(tileY) - gridX / kStepsPerTile) * TS; // renderX + vertex.position[1] = (32.0f - static_cast(tileX) - gridY / kStepsPerTile) * TS; // renderY + vertex.position[2] = chunkBaseZ + h; // renderZ // Normal if (index * 3 + 2 < static_cast(chunk.normals.size())) {