08: Perspective-corrected interpolation
After our move to 3D, objects realistically change shape according to perspective rules and how we look at them. If you look closely, there is an issue though with the interpolation, which is very apparent with an applied texture that has a regular pattern, such as the checkerboard. If you are into retro games, you might have seen a similar phenomenon with PS1 games, where the textures seem to wobble around depending on how you look at it. The main reason is this: We are using the barycentric coordinates from the rasterized triangles to interpolate attributes. This works fine in 2D, since the triangle on the screen is the same one that we specified (disregarding things like scaling and translation for now), but in the process of converting the 3D triangles to the 2D ones, the shapes get distorted. Moving one unit across the 2D rasterized triangle now does not generally correspond to moving one unit on the 3D triangle. But our attributes are supposed to vary linearly on the 3D triangle surface! This is the problem, that this section will try to solve.
You can find the full rasterization code here: Rasterizer 08
Getting back from 2D to 3D (indirectly)
To solve our problem, we will first look at what happens when we project an interpolated point onto the screen. With that knowledge, we can try to get the other way around, since we mostly only have screen triangle information.
First some notation. We will keep track of the homogeneous coordinate separately. The input primitive points will be called . We use an index so our formulas can work for any interpolation, so for , we have a line and for we have a triangle. But you could also do polygons with more than three vertices!
The interpolation parameters are likewise indexed and called . By definition, they should sum to , so
A point on the line/triangle/... can be represented by an interpolation of the vertices:
And if we have any data defined at the vertices, they use the same sets of parameters that are used for the point:
We don't want to get caught up in too much details, so we will just use a general projection matrix specified by . This could also include other transformations, but we don't need to care about that now.
Now we turn to the screen space. We will just add a small to the names to specify that they are in screen space, so is the projection of after the perspective division. But from before, we know that homogeneous points are the same when we multiply them by a scalar. Before the perspective divide, they will have a homogeneous component, which we will just call with a subscript describing the associated point. So we have for example:
Let us now calculate the projection of our point.
We will split that equation into the vector and the homogeneous part, and get our first intermediate results:
We found the screenspace interpolation parameters !
We can also see that they sum to 1, which is expected, as we are computing them from the projected object.
Note: There is a small slight of hand here, as technically we have not shown, that the parameters we compute from the projected lines or triangles are actually the same as . This is the case though, as normalized barycentric coordinates for these shapes are unique, so we won't accidently compute different ones that satisfy the same property!
We already compute the points after perspective division. We don' really need the interpolated x and y coordinates, as the rasterization already works using the barycentric parameters or the line drawing between two points. But one thing we need is the z coordinate. But it turns out from the above, we are already finished with that, because linearly interpolating the z after perspective divide with is actually the correct transform!
(Note: This is not the depth of the 3D point, but the projected depth! You could of course use our interpolation formula that we derive next with the original vertex coordinate. This would give you the actual 3D depth. You could use that as well, but usually the non-linear depth is used in order to maximize precision).
For any other data though, we don't have the values after perspective division. How would that even work for attributes like color? These are not points in space. But we can actually reconstruct the original interpolation parameters from the screenspace parameters .
We just start by inverting .
From above, we also know the expression for , but that contains the original interpolation parameters again. But we know, that they sum to , which we can use to get a new expression for .
We can plug that back in and now have an expression that only depends on the screenspace interpolation parameters and the homogeneous vertex coordinates!
So basically, we divide the screenspace parameters by the corresponding homogeneous coordinates and normalize them by dividing each by the total sum. With that, we can correctly interpolate any kind of data on a perspectively distorted objects!
This result also allows us to find some additional interesting properties!
We can invert both sides the expression of :
So while interpolates with , interpolates with ! We did that in the last step already and stored it in the last coordinate of the fragment coordinate! Turns out, it is the correct transform, just as with depth.
What happens, if we don't have a perspective matrix, but for example rotation, scaling or translation? In that case, all . With that and the property, that the parameters sum to , the expression just becomes:
So for affine transformations, we do not need to change the parameters at all!
With this we have everything we need! We can interpolate data on our lines and triangles and get the correct depth value for each fragment. While we interpolate the 3D values, we don't actually have to leave our rasterized world, but can just adjust the interpolation parameters to take into account the distortion.
Next up, we can put this into our code!
Implementing the corrected interpolation
For our implementation, let us explicitely write out the updated interpolation parameters for lines and triangles, as we only have these two primitives. First, let's restate the generic form.
We will try to follow the same naming for the primitives as we have used when we first interpolated attributes. The line is defined by two points and . We already have to code to compute the interpolation parameter in screenspace .
So the parameters are:
So the overall interpolation formula for data is thus:
We do the same for the triangle with points and barycentric coordinates in screenspace. Previously, we have written the barycentric coordinates as . To avoid confusion with the other variables, we will rename them to , making it clear, which point they belong to.
And the complete interpolation formula with different formulations:
Generally, shaders/graphic APIs will allow you to specify per attribute whether it will be interpolated according to the screen or corrected for perspective. For example, the noperspective qualifier will turn off perspective correction for shader outputs in GLSL. For simplicity, we will make this option a per rendercall operation and expose the interpolation behavior as a pipeline option.
const AttributeInterpolation = {
LINEAR: 0,
PERSPECTIVE_CORRECTED: 1
};
class Pipeline {
...
constructor({
...
attribute_interpolation =
AttributeInterpolation.PERSPECTIVE_CORRECTED,
}) {
...
this.attribute_interpolation =
attribute_interpolation;
...
}
...
}
We will define the methods interpolate_line_perspective and interpolate_triangle_perspective which are the analogs of the interpolation methods we have created before, just with the correction. These are called in the rasterize_line rasterize_triangle methods depending on the value of pipeline.attribute_interpolation. Since this is just a simple condition, it is already implemented, but you can have a look at the change in the code. For example in rasterize_line:
if (pipeline.attribute_interpolation
=== AttributeInterpolation.LINEAR) {
data[i] = this.interpolate_line(
data_a[i], data_b[i], t
);
} else {
data[i] = this.interpolate_line_perspective(
data_a[i], data_b[i],
a.at(3), b.at(3), t
);
}
The solution can be found below, as usual.
Exercise:
- Implement the perspective-corrected line interpolation in
interpolate_line_perspectivein rasterizer.js according to - Implement the perspective-corrected triangle interpolation in
interpolate_triangle_perspectivein rasterizer.js according to
Solution:
With this, we have implemented a working 3D rasterizer, that can already display a wide variety of things!
The next sections will show you some basic lighting and add the important feature of transparency.