!!html_class cgfs !!html_title Lines - Computer Graphics from Scratch
In Part I of this book, we studied raytracing extensively and developed a raytracer that could render our test scene with accurate lighting, material properties, shadows, and reflection using relatively simple algorithms and math. This simplicity comes at a cost: performance. While non-real-time performance is fine for certain applications, such as architectural visualization or visual effects for movies, it's not enough for other applications, such as video games.
In this part of the book, we'll explore an entirely different set of algorithms that favor performance over mathematical purity.
Our raytracer starts from the camera and explores the scene through the viewport. For every pixel of the canvas, we answer the question, "Which object of the scene is visible here?" Now we'll follow an approach that is, in some sense, the opposite: for every object in the scene, we'll try to answer the question "In which parts of the canvas will this object be visible?"
It turns out we can develop algorithms that answer this new question much faster than raytracing could, as long as we're willing to make some accuracy trade-offs. Later, we'll explore how to use these fast algorithms to achieve results with a quality comparable to that of a raytracer.
We'll start from scratch again: we have a canvas of dimensions PutPixel()
, but nothing else. Let's explore how to draw the simplest possible element on the canvas: a line between two points.
Suppose we have two canvas points,
Let's start by representing a line with parametric coordinates, just as we did with rays before (in fact, you can think of "rays" as lines in 3D). Any point
We can decompose this equation into two, one for each coordinate:
Let's take the first equation and solve for
We can now plug this expression for
Rearranging it a bit:
Notice that
What is
Let's go back to the equation. Distributing the multiplication:
Grouping the constants:
Again,
This is the standard formulation of a linear function, which can be used to represent almost any line. When we solved for
To work around this issue, we'll just ignore vertical lines for now and figure out how to deal with them later.
We now have a way to get the value of
We can now write a first approximation of a function that draws a line segment from x0
and y0
be the x1
and y1
those of
DrawLine(P0, P1, color) {
a = (y1 - y0) / (x1 - x0)
b = y0 - a * x0
for x = x0 to x1 {
y = a * x + b
canvas.PutPixel(x, y, color)
}
}
Note that the division operator /
is expected to perform real division, not integer division. This is despite
Also note that we consider for
loops to include the last value of the range. In C, C++, Java, and JavaScript, among others, this would be written as for (x = x0; x <= x1; ++x)
. We will be using this convention throughout this book.
This function is a direct, naive implementation of the equation above. It works, but can we make it faster?
We aren't calculating values of
We can manipulate that second expression a bit:
This shouldn't be surprising; after all, the slope
This means we can compute the next value of
Again assuming that
DrawLine(P0, P1, color) {
a = (y1 - y0) / (x1 - x0)
y = y0
for x = x0 to x1 {
canvas.PutPixel(x, y, color)
y = y + a
}
}
So far we've been assuming that P0
and P1
to transform it into the left-to-right version of the same line, and draw it as before:
DrawLine(P0, P1, color) {
// Make sure x0 < x1
if x0 > x1 {
swap(P0, P1)
}
a = (y1 - y0) / (x1 - x0)
y = y0
for x = x0 to x1 {
canvas.PutPixel(x, y, color)
y = y + a
}
}
Let's use our function to draw a couple of lines. Figure 6-1 shows the line segment
The line appears jagged because we can only draw pixels on integer coordinates, and mathematical lines actually have zero width; what we're drawing is a quantized approximation of the ideal line from
Let's try another line,
Oops. What happened?
The algorithm did exactly what we told it to; it went from left to right, computed one value of
This happens because we chose a formulation where
Choosing
DrawLine(P0, P1, color) {
// Make sure y0 < y1
if y0 > y1 {
swap(P0, P1)
}
a = (x1 - x0)/(y1 - y0)
x = x0
for y = y0 to y1 {
canvas.PutPixel(x, y, color)
x = x + a
}
}
This is identical to the previous DrawLine
, except the
We can just keep both versions of the function and choose which one to use depending on the line we're trying to draw. And the criterion is quite simple; does the line have more different values of
Listing 6-1 shows a version of DrawLine
that handles all the cases.
DrawLine(P0, P1, color) {
dx = x1 - x0
dy = y1 - y0
if abs(dx) > abs(dy) {
// Line is horizontal-ish
// Make sure x0 < x1
if x0 > x1 {
swap(P0, P1)
}
a = dy/dx
y = y0
for x = x0 to x1 {
canvas.PutPixel(x, y, color)
y = y + a
}
} else {
// Line is vertical-ish
// Make sure y0 < y1
if y0 > y1 {
swap(P0, P1)
}
a = dx/dy
x = x0
for y = y0 to y1 {
canvas.PutPixel(x, y, color)
x = x + a
}
}
}
This certainly works, but it isn't pretty. There's a lot of code duplication, and the logic for selecting which function to use, the logic to compute the function values, and the pixel drawing itself are all intertwined. Surely we can do better!
We have two linear functions
Of course, any function can be written as
Interpolate (i0, d0, i1, d1) {
values = []
a = (d1 - d0) / (i1 - i0)
d = d0
for i = i0 to i1 {
values.append(d)
d = d + a
}
return values
}
This function has the same "shape" as the first two versions of DrawLine
, but the variables are called i
and d
instead of x
and y
, and instead of drawing pixels, this one stores the values in a list.
Note that the value of values[0]
, the value for values[1]
, and so on; in general, the value for values[i_n - i_0]
, assuming
There's a corner case we need to consider: we may want to compute
Interpolate (i0, d0, i1, d1) {
if i0 == i1 {
return [ d0 ]
}
values = []
a = (d1 - d0) / (i1 - i0)
d = d0
for i = i0 to i1 {
values.append(d)
d = d + a
}
return values
}
As an implementation detail, and for the remainder of this book, the values of the independent variable
Now we can write DrawLine
using Interpolate
.
DrawLine(P0, P1, color) {
if abs(x1 - x0) > abs(y1 - y0) {
// Line is horizontal-ish
// Make sure x0 < x1
if x0 > x1 {
swap(P0, P1)
}
ys = Interpolate(x0, y0, x1, y1)
for x = x0 to x1 {
canvas.PutPixel(x, ys[x - x0], color)
}
} else {
// Line is vertical-ish
// Make sure y0 < y1
if y0 > y1 {
swap(P0, P1)
}
xs = Interpolate(y0, x0, y1, x1)
for y = y0 to y1 {
canvas.PutPixel(xs[y - y0], y, color)
}
}
}
This DrawLine
can handle all cases correctly (Figure 6-5).
While this version isn't much shorter than the previous one, it cleanly separates the computation of the intermediate values of
It might come as a surprise that this line algorithm is not the best or the fastest there is; that distinction probably belongs to Bresenham's Algorithm. The reason to present this algorithm is twofold. First, it is easier to understand, which is an overriding principle in this book. Second, it gave us the Interpolate
function, which we will use extensively in the rest of this book.
In this chapter, we've taken the first steps to building a rasterizer. Using the only tool we have, PutPixel
, we've developed an algorithm that can draw straight line segments on the canvas.
We have also developed the Interpolate
helper method, a way to efficiently compute values of a linear function. Make sure you understand it well before proceeding, because we'll be using it a lot.
In the next chapter, we'll use Interpolate
to draw more complex and interesting shapes on the canvas: triangles.