Noisy shader output, but only when adding 0.5 to the texture coordinate

So I’m seeing some baffling behavior from my GLSL shaders. They do the bog-standard thing where you send in the texture coordinate as a vertex attribute to the vertex shader, and then pass it on through to the fragment shader, which then looks up the appropriate color in a bound texture. However, if I add 0.5 to the texture coordinate in the vertex shader, I see what looks like Z-fighting noise in the rendered output. No idea what might be causing this; I’m quite confident that there are not actually two triangles in the same place here. It also doesn’t replicate if I add 0.49, or 0.51; just 0.5. Anyone ever seen something like this…?

Here’s the vertex shader:

attribute vec3 i_position;
attribute vec2 i_texCoord;
attribute float i_heightScale;

varying vec2 texCoord; 

uniform sampler2D i_heightmapTexture;
uniform float i_globalHeightScale;
uniform vec2 i_topLeft;
uniform vec2 i_bottomRight;

void main() 
	texCoord = i_topLeft + i_texCoord * (i_bottomRight - i_topLeft);

	float heightmapSample = texture2D(i_heightmapTexture, texCoord).r;
	float localHeight = heightmapSample * i_heightScale * i_globalHeightScale;

	// Added this line to explicitly demonstrate the problem. I originally saw it when
	// the input values above resulted in adding particular values to the texture coordinate.
	texCoord.y = i_texCoord.y + 0.50;

    gl_Position = gl_ProjectionMatrix * gl_ModelViewMatrix * vec4(i_position.x, localHeight, i_position.z, 1.0);

And the fragment shader:

varying vec2 texCoord;

uniform sampler2D i_colormapTexture;

void main() 
	gl_FragColor = texture2D(i_colormapTexture, texCoord);

Additional fact: I’m using nearest-neighbor filtering for the noisy texture (i_colormapTexture.) I suspect that has something to do with it, as when I switch to linear filtering the problem disappears. I’ve run into this situation in the past where nearest-neighbor filtering is inconsistent across a triangle. Unfortuantely, I need to use nearest-neighbor since (at some point) the colormap texture is going to be used to index terrain types instead of directly color the surface.

Note that you aren’t simply offsetting the coordinate by 0.5 relative to what it would have been, you’re also omitting the scale factor (i_bottomRight - i_topLeft).

Noise in nearest-neighbour sampling is most noticeable if you end up sampling exactly at texel edges (or worse, corners), meaning that the effect of rounding error is maximised.

With a perspective projection and arbitrary view orientation, it’s rather unlikely that you end up consistently sampling at texel edges, but it’s quite possible with an orthographic projection and/or a lack of rotation.