To whom it may assist:
I spent a good while repeatedly pondering and revising a way to place an image on the screen with pixel-perfect precision, and, after finally succeeding, I thought I might write a guide in light of the many pitfalls I encountered along the way.
For what it’s worth, for my purpose, I was trying to layer a pre-rendered image atop another render (think post-processing effects). In some cases, this might come out just fine, whether because they were rendered with the same dimensions anyway, or because any error is negligible. However, this was not the case for me; I needed to pursue perfectly accurate placement, as the transferred image was appearing slightly down and to the right of the underlying render.
So, firstly, if you’ve been working in 3D graphics, you should know that the viewport has x coordinates of -1 to 1 from left to right, and y coordinates -1 to 1 from bottom to top. You should also know that texture coordinates, on the other hand, range 0 to 1 from left to right and top to bottom.
Perfecting the viewport
First, let’s look at how to place our polygons exactly into the viewport. Using vertex coordinates of -1 and 1 will get the job done dirtily, but if we’re going for precision, there are a few things we need to examine more closely.
As it turns out, the right edge of the viewport is not x=1. x=1 is actually one pixel off the right side. So, what is the proper x coordinate to use?
x=-1 is indeed the left edge, but, because we’re dealing with an even number of pixels (presumably), x=0 is not exactly in the centre, as there is no exact centre. Rather, x=0 begins the right half of the viewport.
So, with that in mind, let’s work out the relationship with a linear equation, where viewport x-coordinate -1 corresponds to pixel position 0 (the left edge), and 1 corresponds to w (the number of pixels across, even though this coordinate is actually one pixel off the right edge). What, then, is the proper value to get us one pixel to the left of 1?
Using the coordinate pairs (-1, 0) and (1, w), the linear function mapping texture coordinates to pixel coordinates is y = w/2 x + w/2. Therefore, to achieve a pixel coordinate of w-1:
w-1 = w/2 x + w/2
w/2 - 1 = w/2 x
(w/2 - 1) / (w/2) = x
1 - 1/(w/2) = x
x = 1 - 2/w
So, for example, if our viewport is 1280 pixels across, we want the right edge to correspond to pixel x-coordinate 1279, and therefore we would use a vertex x-coordinate of 0.9984375.
HOWEVER!
In practice, this still doesn’t work out. At this point, my polygon was now ending one pixel short of the right edge. What gives?
Well, it turns out that that figure above, 0.9984375, cannot be stored precisely in a 32-bit float, and as such, was getting rounded down. Therefore, we actually have to aim ever so slightly higher, so that it rounds down to the proper pixel position. Let’s instead shoot for a desired pixel coordinate of 1279.5 - only half a pixel short of 1 - which causes us to recalculate the equation above.
w - 1/2 = w/2 x + w/2
w/2 - 1/2 = w/2 x
(w/2 - 1/2) / (w/2) = x
1 - (1/2)/(w/2) = x
x = 1 - 1/w
This solution makes sense in that it’s 1/w short of 1, where 2/w (or 1/(w/2)) is one whole pixel.
So, finally, we find that, for a viewport width of 1280 pixels, we want a pixel vertex coordinate around 0.99921875. When the GPU goes to round this down to the nearest pixel, it will land on 1279, just as we would want.
This same philosophy applies to the y direction, in that the actual y=-1 is one pixel off the bottom of our screen, or -1 + 1/h. So, if my viewport is 720 pixels tall, I want my vertices to be located at y=1 and y=-0.9986111.
(Also mind that this strategy, with minor alterations, would be valid for placing vertices into any arbitrary pixel-perfect region of the viewport, such as for sprites or tiling, etc.)
Perfecting the texture coordinates
Now that we’ve got our geometry perfectly aligned with our viewport, we have to worry about the texture coordinates. Now, if you were trying this hastily, you would probably use texcoords 0-1 for both left-to-right and top-to-bottom. However, this too is a misunderstanding. See, texcoord 0 (for both x and y dimensions) is not the first pixel in the texture, but rather the seam where the texture wraps around. The same is true of texcoord 1. In fact, texcoords 0 and 1 are exactly the same point - the seam about which the image wraps. As such, if you sample that point, it will be a blend of the first and last pixels in that row/column. Instead, we want to step half a texture pixel forward from that, to get to the centre of that first pixel, so, +(1/2)/w or +1/(2w). If our texture is, say, 1024 pixels across, then we have to make our first texture coordinate .5/1024=1/2048=0.00048828125. (Note, this metric is now in terms of the texture resolution, as opposed to the destination viewport resolution that we used above when placing the vertices!)
So, now we’ve successfully established a vertex at pixel position 0 that will sample the exact centre of the leftmost texture pixel, but now we need pixel position viewport.w-1 to sample the exact centre of pixel texture.w-1 - that is, half a pixel forward of texture.w-1, so texture.w-1/2. (In my example, I want viewport pixel 1279 to sample texture pixel 1023.5.)
However, recall that that’s not where we placed our right-edge vertex; we put that at x=viewport.w-1/2! Whatever texcoord we associate with that vertex must correspond to that assigned point, x=viewport.w-1/2, even though it will be sampled from viewport pixel x=viewport.w-1.
I’ll make another linear equation, mapping pixel coordinate 0 to texture coordinate 1/(2w), and pixel coordinate viewport.w-1 to (texture.w-1/2)/texture.w, or 1-1/(2texture.w) (that is, half a pixel back from the right edge).
data points (0, 1/(2tw)) and (vw - 1, 1-1/(2texture.w))
tx = (1- 1/(2tw) - 1/(2tw)) / (vw - 1) vx + 1/(2tw)
tx = (1- 1/tw) / (vw - 1) vx + 1/(2tw)
Now substitute our hypothetical viewport pixel position (which, remember, is viewport.w - 1/2, where we placed the vertex) for vx:
tx = (1- 1/tw) * (vw - 1/2) / (vw - 1) + 1/(2tw)
Oof, that looks rather messy, doesn’t it? If you wish to comprehend what’s happening, perhaps you can look at it as a “lerp” function, extrapolating half a pixel beyond our original data points above.
So, if, as in my own example, I have a texture with a width of 1024 pixels and a viewport width of 1280, I want the texcoord for my vertex at pixel position 1279.5 to be (1 - 1/1024) * (1279.5) / 1279 + 1/2048, or 0.9999022674.
Putting it all together
I do hope that this explanation helps someone out there! Please comment if you have any questions or feedback. Cheers!