Computer Graphics · Texturing
Texel Grids, Normalized Coordinates, and Wrap Addressing
Where texel centers sit inside a texture, how a normalized coordinate addresses them without an off-by-half error, and what each wrap mode does past the edge.
A texture is not an image. An image is an array of pixels; a texture is a function of two continuous parameters, reconstructed from an array of samples. The array is what memory holds, but the sampler never indexes it directly with those coordinates; it converts a normalized coordinate into a position on the texel grid, decides which texels surround that position, and weights them. Every artifact that looks like “the texture is shifted by half a pixel” or “the edges smear” comes from that conversion, not from the image data.
Why texel centers sit at half-integer positions
Take a texture that is $W$ texels wide. Texel $i$, counting from zero, occupies the interval $[i/W, (i+1)/W)$ in normalized coordinate space, and its center is at
$$u_i = \frac{i + 0.5}{W}$$
The half is not a rounding convention, it is the definition of a box filter’s centroid: the sample stored in texel $i$ represents the average of the underlying continuous image over the cell that texel owns, and the representative point of that cell is its middle. A 256-texel-wide texture therefore has texel centers at $1/512, 3/512, 5/512, \dots$, and its last center is at $511.5/512$. The value $u = 0$ is the left edge of texel 0, not its center, and $u = 1$ is the right edge of the last texel.
Nearest-neighbor sampling is the identity between the two conventions:
/* Nearest sample of a W x H texture from normalized coordinates.
Result is the texel index whose cell contains (u, v). */
int x = (int)floorf(u * (float)W);
int y = (int)floorf(v * (float)H);
Bilinear sampling needs the fractional position relative to the texel centers, so it subtracts half a texel first:
/* Position in texel-space, measured from texel 0's center. */
float fx = u * (float)W - 0.5f;
float fy = v * (float)H - 0.5f;
int x0 = (int)floorf(fx); /* may be -1 at the left edge */
int y0 = (int)floorf(fy);
float ax = fx - (float)x0; /* 0 <= ax < 1 */
At $u = 0$ this produces $x_0 = -1$ with weight $0.5$ on texel $-1$ and $0.5$ on texel $0$. That negative index is not a bug; it is the sampler reaching outside the surface, which is exactly the case the wrap mode exists to resolve. A shader that clamps x0 to zero instead of applying the wrap rule collapses that blend to texel 0 alone, so the left and top edges of a repeating pattern lose their half-texel transition while the interior keeps it, which shows as a seam that appears under one wrap mode and not another.
Two origins for one array
Rows are stored in memory in increasing order, with row 0 first. Direct3D treats $v = 0$ as the top row; OpenGL treats $v = 0$ as the bottom row. Neither is more correct, but the mismatch is the single most common source of inverted images, and it is not always obvious which side flipped. Green-channel normal maps encode the y component of a surface normal in texture space, so an importer that flips $v$ without also flipping the green channel produces lighting that looks subtly wrong rather than obviously upside down. Framebuffer-to-texture paths have the same trap: a render target written with the origin at the top and then sampled with the convention of the bottom yields a vertically mirrored copy, which is harmless for symmetric content and wrong for anything directional.
The array order itself is part of the addressing contract for mip chains and array layers. Level 0 is the full-size image; each subsequent level halves both dimensions, and sampling a level means applying the same half-texel rule with that level’s dimensions. The wrap mode applies per level, which matters because a 4-texel-wide level has a wrap period of a quarter of the base level’s.
What each wrap mode does to a coordinate
The wrap mode is a function applied to the coordinate before the texel-space conversion. Applied per axis, independently, and it is the only thing that makes an out-of-range coordinate meaningful.
| Mode | Transformation | Behaviour at the seam |
|---|---|---|
| Repeat | $u’ = u - \lfloor u \rfloor$ | Pattern continues; texel $W-1$ neighbours texel 0 |
| Mirrored repeat | period 2: $p = u - 2\lfloor u/2 \rfloor$, then $p’ = p \le 1 ? p : 2 - p$ | Pattern continues with alternating orientation |
| Clamp to edge | $u’ = \min(\max(u, 0), 1)$ | Last texel is extended indefinitely |
| Clamp to border | coordinate tested against the texel grid; outside yields the border colour | A hard edge, with the border colour blended in |
Repeat is written with floor, not with fmod or with a cast to int. C’s fmodf(-0.25f, 1.0f) returns -0.25f, and a cast truncates toward zero, so both leave a negative fractional part that then indexes below texel 0. The floor form is the correct one for any sign of input. On hardware with power-of-two dimensions the fractional part is often reduced with a mask on the fixed-point representation, because $i \bmod 2^n = i \mathbin{&} (2^n - 1)$ costs one instruction where a general modulo costs many; that is the origin of the long-standing preference for power-of-two textures.
Clamp to edge is what a sampler does when the wrap mode is unspecified or unsupported, and it is the safe default for atlases that have been padded. Clamp to border is different in kind: it does not extend any texel, it substitutes a constant for samples whose footprint falls outside the grid. The border region is defined in texel space, so a bilinear tap that straddles the boundary blends the border colour with the edge texel over a half-texel band. Setting the border colour to transparent black gives a hard-edged sprite; setting it equal to the edge colour reproduces clamp to edge at twice the bandwidth cost.
Precision, exact 1.0, and coordinates that never wrap
Three boundary cases deserve explicit handling. The first is $u = 1.0$ exactly. Under repeat, floor of 1.0 is 1 and the fractional part is 0, so the position sits on the boundary between texel $W-1$ and texel 0. A nearest fetch returns texel 0; a bilinear fetch returns half of texel $W-1$ and half of texel 0, which is the smooth continuation of a tiling pattern and the wrong answer for a texture that is not tileable. Under clamp to edge, 1.0 is inside the range and converts to a position half a texel past the last centre, so the bilinear fetch blends the last texel with itself and returns it unchanged. The two modes therefore disagree at exactly one coordinate, and a quad authored with UVs spanning $[0,1]$ inherits whichever answer the sampler state selects. Authors who want the full image reproduced with no wrap contribution use $[0, 1 - 1/W]$ for the last vertex.
The second is interpolation precision. Texture coordinates arrive from the rasterizer as interpolated floats or half-floats, and the sampler converts them to a fixed-point texel-space position with a limited number of fractional bits. A half-float carries 10 explicit mantissa bits, so in the interval $[0.5, 1)$ its spacing is $2^{-11}$, about one part in 2048. For a texture wider than about 2048 texels that spacing is coarser than one texel, and adjacent screen pixels can quantize to the same address. Wide textures combined with half-float coordinate interpolation produce visible stair-stepping that no filtering setting removes.
The third is the derivative discontinuity. A sampler chooses a mip level from the screen-space rate of change of the coordinates, and a mesh that spans the wrap seam has a coordinate jump from $0.99$ to $0.01$ between two vertices. The difference is large, the estimated rate of change is enormous, and the sampler selects the smallest level: the surface blurs exactly along the seam. This is not a wrap-mode failure but a consequence of computing derivatives on wrapped coordinates, and the fix is geometric: duplicate the seam vertices so no triangle straddles the discontinuity, as described in deriving mip level from screen-space derivatives.
How addressing constrains everything downstream
The texel-centre convention is the shared contract that the rest of the sampling path depends on. Bilinear reconstruction is defined relative to these centres, so its weights sum to one only if the coordinate conversion subtracts the half texel. Mip chain construction halves the grid dimension at each level, which means each level’s centre positions shift; a level built by dropping every other texel rather than averaging a 2×2 block is offset by half a texel from the level above and shimmers when blended. And swizzled memory layouts change which texel is physically adjacent to which, which is invisible to the coordinate arithmetic but determines whether the four taps of a bilinear fetch cost one cache line or four.
The practical rule that follows: treat the coordinate as living in a continuous space whose sample points are at half-integer positions, apply the wrap mode as a function on that continuous space, and only then convert to indices. Doing it in the other order, by clamping indices or masking integers or letting the interpolation stage repair range, produces edge behavior that depends on filter mode and mip level, which is the kind of inconsistency that survives testing on one asset and fails on the next.
References
- The OpenGL Graphics System: A Specification, Chapter 8: Textures and Samplers — Khronos Group
- OpenGL Wiki: Sampler Object — Khronos Group
- Fundamentals of Texture Mapping and Image Warping — Paul Heckbert, MSc thesis, UC Berkeley, 1989