Workaround for texture coordinate problem on border of terrain “chunk”?

It took me a while to diagnose what was going on here but I think I’ve figured out what the problem is, I’m just not sure how to go about fixing it. I’m working on a streaming clipmap terrain where a large heightmap is split into a grid of smaller heightmaps, then at runtime I load in a (at the moment 11×11, but it’s configurable) grid of heightmap chunks into a 2D texture array, along with a small layer index map that I can sample at chunk coordinates (x, y) to determine which array layer that chunk’s heightmap is stored. Layers become recyclable when the camera moves out of range, and new chunks get loaded in.

So far the heightmapping works perfectly, there are no gaps in the terrain. However now I have added normal mapping in using the same method (another identically sized 2D texture array using the same layer indexes as the heightmap array for simplicity), I’ve come across an issue that happens with polygons on the outer edges of each chunk.

Terrain normal map artefact

Terrain normal map artefact wireframe

In the screenshots above the axis helper is placed exactly on (x, z) = (512.0, 512.0) (Y is "up"). Each square of terrain vertices at the lowest clipmap resolution is always equal to 1 pixel of heightmap and normal map. This makes determining vertex height a simple matter of using texelFetch. I’ve added a variable called IVS (intra-vertex spacing) to control how many world units each pixel represents. In all my screenshots I’m using a 32×32 grid of 1024×1024 heightmaps with an IVS of 0.5, so each chunk is 512u and 1024 pixels.

Unfortunately if I colour the terrain based on normal map coordinates I can see the problem (I am pretty sure) is on the edge squares. For the squares on the chunk edges the face of the poly is shaded from tex coordinate 1023/1024 to 0.0, rather than from 1023/1024 to 1024/1024/1.0, or that’s at least what it looks like from this:

NOTE: in this screenshot, 511 should be 511.5. Apologies. Terrain coloured by normal map coordinates

Essentially because the texture coordinates for a vertex at the "start" (top left) of a chunk are always 0,0, at no point do any vertices get assigned 1,1. Is there a way I can tell OpenGL that I want world coordinates 512,512 to be texture coordinates 1,1 when shading the triangles between it and 511.5,511.5, and 0,0 for the ones on the other side?

In case my analysis was wrong or my explanation wasn’t great here’s my shader code. Maybe there is something easy I have missed in here, but from what I can tell the numbers work:

//Vertex shader for terrain. #include <attributes>  uniform mat4 ModelViewMatrix; uniform mat3 NormalMatrix; uniform mat4 MVP;  uniform float Scale; uniform vec2 Offset; uniform mat4 RotMatrix; uniform float IVS; uniform float MinHeight; uniform float MaxHeight; uniform int ChunkSize; uniform int MapWidth; uniform int MapHeight; uniform sampler2DArray Heightfield; uniform sampler2D LayerIndexMap;  out float Height; out vec3 NormalMapCoords;  void main() {     // Get the XZ world coordinates of the vertex.     vec2 vertXZ = Offset + (RotMatrix * vec4(VertexPosition, 1.0)).xz * Scale;     float chunkWorldSize = ChunkSize * IVS;      // Get the chunk xy and the layer index to sample from.     int chunkX = int(floor(vertXZ.x / chunkWorldSize));     int chunkY = int(floor(vertXZ.y / chunkWorldSize));     float layerIdx = texelFetch(LayerIndexMap, ivec2(chunkX, chunkY), 0).r * 256.0;      // Scale the vertex coords to the chunk image size.     vec2 pixelXY = vec2(         mod(vertXZ.x, chunkWorldSize) / IVS,         mod(vertXZ.y, chunkWorldSize) / IVS     ); // if IVS = 0.5 and vertXZ = 511.5, then pixelXY = 1023     float sampledHeight = texelFetch(Heightfield, ivec3(pixelXY.x, pixelXY.y, layerIdx), 0).r;      NormalMapCoords = vec3(         pixelXY.x / float(ChunkSize),         pixelXY.y / float(ChunkSize),         layerIdx     );      float heightMod = int(chunkX >= 0 && chunkX < MapWidth && chunkY >= 0 && chunkY < MapHeight);     sampledHeight = sampledHeight * heightMod;      Height = ((MaxHeight - MinHeight) * sampledHeight) + MinHeight;     vec3 finalPos = vec3(VertexPosition.x, Height, VertexPosition.z);      // Set the vertex position.     gl_Position = MVP * mat4(1.0) * vec4(finalPos, 1.0); } 
//Fragment shader for terrain. uniform vec3 SunVec; uniform sampler2DArray NormalMap; uniform int MeshType; //TODO debug uniform int RotIndex; //TODO debug  in float Height; in vec3 NormalMapCoords;  out vec4 fragColor;  // All components are in the range [0…1], including hue. vec3 hsv2rgb(vec3 c) {     vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);     vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);     return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); }  void main() {     //vec3 baseCol = vec3(hsv2rgb(vec3(106.0 / 255.0, 1, 1))); // Flat colour.     //vec3 baseCol = vec3(hsv2rgb(vec3(Height, 1, 1))); // Colour by height.     vec3 baseCol = vec3(hsv2rgb(vec3((NormalMapCoords.x + NormalMapCoords.y) / 2.0, 1, 1))); // Colour by normal map coords.      vec3 ambient = vec3(0.1, 0.1, 0.1); //TODO use a combination of colour control maps and daylight in-shadow light intensity to calc this (col * intensity)      vec2 normalXZ = texture(NormalMap, NormalMapCoords, 0).rg;     float normalY = 1.0 - (normalXZ.x + normalXZ.y);     float sunlight = max(0.0, dot(normalize(vec3(normalXZ.x, normalY, normalXZ.y)), normalize(SunVec)));      vec3 finalCol = (ambient + sunlight) * baseCol;     fragColor = vec4(finalCol, 1.0); } 

The normal map is using LINEAR for both filters and CLAMP_TO_EDGE for S and T wrapping, and I should also point out a chunk is not a single mesh. The clipmap follows the camera around and covers multiple chunks.