Skip to content

Commit

Permalink
Merge branch 'cscheid-master'
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelDiBernardo committed May 14, 2014
2 parents 9df7ffe + 8c61469 commit a06a4b1
Show file tree
Hide file tree
Showing 22 changed files with 1,007 additions and 0 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,16 @@ Contributors
</td>
<td><a href="https://github.com/haz">haz</a></td>
<td>christian.muise@gmail.com</td>
<tr>
<td>Carlos Scheidegger</td>
<td>AT&amp;T Research</td>
<td>rasterizer</td>
<td>
<ul>
<li><a href="https://twitter.com/cjmuise">@cscheid</a></li>
</ul>
</td>
<td><a href="https://github.com/cscheid">cscheid</a></td>
<td>carlos.scheidegger@gmail.com</td>
</tr>
</table>
51 changes: 51 additions & 0 deletions rasterizer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# `tiny_gfx`: A tiny rasterizer

A *rasterizer* is a piece of software that converts arbitrary shapes
into *raster images*, which is just a funny name for rectangular grids
of pixels. A rasterizer, in some way or another, is at the heart of
pretty much every modern display technology today, from computer
displays to e-ink displays to printers both 2D and 3D (the graphics in
laser shows are the most notable exception).

In this chapter, I will teach you a little about how rasterizers work,
by describing `tiny_gfx`, a simple rasterizer in pure Python. Along
the way we will pick up some techniques that show up repeatedly in
computer graphics code. Having a bit of mathematical background on
linear algebra will help, but I hope the presentation will be
self-contained.

`tiny_gfx` is not practical in many ways. Besides being slow (see
below), the main shortcoming in `tiny_gfx` is that shapes are all of a
single solid color. Still, for 500 lines of code, `tiny_gfx` has a
relatively large set of features:

- alpha blending for semi-transparent colors
- polygonal shapes (possibly concave, but not self-intersecting)
(TODO: right now only convex polygons are supported)
- circles and ellipses
- transformations of shapes
- boolean operations on shapes (union, intersection, subtraction)
- antialiasing
- empty space skipping and fast rasterization of runs for general
shapes

Maybe most interestingly, shapes in `tiny_gfx` are extensible. New
shapes are easily added, and they compose well with the other parts of
the code.

A description of the current version of the code is in `doc/README.md`.

## A performance caveat

Rasterizers are so central to display technology that their
performance can make or break a piece of software, and these days the
fastest rasterizers are all based in hardware. Your videogame graphics
card (and even the cheapest smartphones these days) is rasterizing
polygons in highly parallel processors; 192 cores is a typical
number. It should be no surprise, then, that the rasterizer we will
see here is slow: if CPU-intensive tasks in Python run around 50 times
slower than heavily-optimized, low-level code, and if a graphics
driver has around 200 cores at its disposal, a slowdown of 10,000
times should not be surprising. In reality, `tiny_gfx` is closer to
1,000,000 times slower than the special-purpose graphics rasterizer
from the laptop in which I'm developing it.
141 changes: 141 additions & 0 deletions rasterizer/doc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Overview

By far, the most important class in this project is `Shape`, in
`shape.py`.


## `shape.py`

`Shape` is the main class of `tiny_gfx`. The class itself contains a
very basic constructor, and the main code for the scanline rasterizer
itself, in `Shape.draw`.

Concrete subclasses of `Shape` need to implement two methods:
`contains` and `signed_distance_bound`. Both methods take a point in
the plane. `contains` should return true if the shape contains the
point (no surprise there). `signed_distance_bound` is the main method
used by `tiny_gfx` to *accelerate* the rendering. The idea is that
`signed_distance_bound` should return a real value that describes a
*distance certificate*. If this value (call it `r`) is negative, then the shape is
promising that not only is the point is not in the shape, but no
points inside a ball of radius `-r` around the point is in the shape as
well. If the value is positive, then the shape is
promising that not only is the point in the shape, but a ball of
radius `r` is entirely contained in the shape as well. If the shape
cannot guarantee anything either way, it should return zero. In other
words, `signed_distance_bound` is used by `Shape.draw` to skip large
numbers of checks against `contains`, both inside and outside the
shape. It's ok if `signed_distance_bound` is conservative. In fact,
always returning zero would be correct behavior. It would just be
much slower.

Finally, concrete subclasses of `Shape` must have a `self.bound` field
that stores an `AABox` object that represents an axis-aligned bounding
box of the object. The bounding box does not need to be exact, but it
must be *conservative*: `self.contains(p)` must imply
`self.bounds.contains(p)`. The smaller the area of this box, the more
efficient rasterization will be.

`Shape.draw` works by traversing candidate image pixels inside the
shape bounding box in row order. Anti-aliasing is performed by
jittered sampling (see Figure 5.16
[here](http://www.cs.utah.edu/~shirley/papers/thesis/ch5b.pdf)). Note
that if `Shape.draw` knows that the pixel is entirely inside the image
(because `r` from above is greater than the length of a pixel
diagonal), then no anti-aliasing tests are needed. This allows the
default setting for super-sampling to be to take 36 samples inside a
pixel with no large loss in performance. By the same token, if `r` is
a negative number, then `Shape.draw` skips those pixels with no
further checks. Anti-aliasing is performed by counting the number of
points that pass the `contains` call, and updating the image pixel.



## `geometry.py`

This file contains geometry classes generally needed for the
rasterizer.

* `Vector`, a 2D vector with your basic operator overloading and
methods. In this code we use this class to store both points and
vectors. There are reasons why this is a bad idea, but for sake of
simplicity and brevity, we do it.

* `AABox`, a 2D axis-aligned bounding box

* `HalfPlane`, a class that models one-half of the 2D plane by a
linear equation

* `Transform`, a class for 2D affine transformations of `Vector`s

* utility functions to build transforms (which should perhaps be
`classmethod`s of Transform, except that using them leads to long,
unreadable lines for things that should have short names)


## `color.py`

* `Color`: Self-contained RGBA class in plain old dumb RGBA.

## `csg.py`

This file contains classes used for Boolean operations with
shapes. (CSG stands for Constructive Solid Geometry, the three-letter
acronym used in graphics for the idea). The base class is `CSG`,
and there's a class for each supported Boolean operation: `Union`,
`Intersection` and `Subtraction`.

If the `signed_distance_bound` and `contains` ideas above makes sense,
then the code for the three classes should be self-explanatory.


## `ellipse.py`

`Ellipse` is the most complicated `Shape` available, and is presented
as an example of the generality of the approach used here. The
internal representation for an ellipse is the implicit equation form,
which defines the ellipse as the set of points for which a certain
function is less than 0. In this case, the function is a quadratic
polynomial in x and y.

`Ellipse.contains` simply evaluates the implicit equation.

The code for `Ellipse.signed_distance_bound` is actually relatively
clever and non-trivial. This flavor of geometric insights is prevalent
in graphics, so I wanted to give an actual example of it in the
code. However, it takes a diagram to show why it works, so I don't
really expect you to understand it without comments.

I have to draw a diagram to explain how it
works, so if you run into trouble on that one, send me a message and
I'll move it to the top of my priority queue.

There's more description of the code for `Ellipse` under `rasterizer/ellipse.md`.

## `image.py`

`PPMImage`: Simple, self-contained class that stores a two-dimensional array of
`Color` pixels, and writes them as a
[PPM file](http://netpbm.sourceforge.net/doc/ppm.html).


## `poly.py`

`ConvexPoly` represents a convex polygon by a set of
half-planes. `Shape.contains` simply tests all half-planes
corresponding to each edge of the polygon, and
`Shape.signed_distance_bound` takes the most conservative value across
all of the shape half-planes. This actually gives values of 0 for
points on the "infinite line" spanned by polygon edges, but that's
fine because the result needs only be conservative.


## `scene.py`

`Scene` stores a hierarchical scene graph, where each node is either a
`Shape` or a `Scene` itself. In addition, each `Scene` object carries
an affine transform that's applied to every element under it. By
having different scenes holding the same object lists with different
transformations, it becomes easy to express scenes with repeated
elements.

55 changes: 55 additions & 0 deletions rasterizer/doc/ellipse_1.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions rasterizer/rasterizer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from shape import *
from geometry import *
from poly import *
from color import *
from ellipse import *
from image import *
from scene import *
from csg import *
36 changes: 36 additions & 0 deletions rasterizer/rasterizer/color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class Color:
def __init__(self, r=0, g=0, b=0, a=1, rgb=None):
self.rgb = rgb or (r, g, b)
self.a = a
def draw(self, o):
if self.a == o.a == 0.0:
return
if o.a == 1.0:
self.rgb = o.rgb
self.a = 1
else:
u = 1.0 - o.a
self.rgb = (u * self.rgb[0] + o.a * o.rgb[0],
u * self.rgb[1] + o.a * o.rgb[1],
u * self.rgb[2] + o.a * o.rgb[2])
self.a = 1.0 - (1.0 - self.a) * (1.0 - o.a)
def fainter(self, k):
return Color(rgb=self.rgb, a=self.a*k)
def as_ppm(self):
def byte(v):
return int(v ** (1.0 / 2.2) * 255)
return "%c%c%c" % (byte(self.rgb[0] * self.a),
byte(self.rgb[1] * self.a),
byte(self.rgb[2] * self.a))
def __repr__(self):
return "[" + str(self.rgb) + "," + str(self.a) + "]"
@staticmethod
def hex(code, a=1):
if len(code) == 4:
return Color(int(code[1], 16) / 15.0,
int(code[2], 16) / 15.0,
int(code[3], 16) / 15.0, a)
elif len(code) == 7:
return Color(int(code[1:3], 16) / 255.0,
int(code[3:5], 16) / 255.0,
int(code[5:7], 16) / 255.0, a)
45 changes: 45 additions & 0 deletions rasterizer/rasterizer/csg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from shape import Shape
from geometry import *

class CSG(Shape):
def __init__(self, v1, v2, color=None):
Shape.__init__(self, color or v1.color or v2.color)
self.v1 = v1
self.v2 = v2
def transform(self, t):
return self.__class__(self.v1.transform(t), self.v2.transform(t),
color=self.color)

class Union(CSG):
def __init__(self, v1, v2, color=None):
CSG.__init__(self, v1, v2, color=color)
self.bound = AABox.from_vectors(v1.bound.low, v1.bound.high,
v2.bound.low, v2.bound.high)
def contains(self, p):
return self.v1.contains(p) or self.v2.contains(p)
def signed_distance_bound(self, p):
b1 = self.v1.signed_distance_bound(p)
b2 = self.v2.signed_distance_bound(p)
return b1 if b1 > b2 else b2

class Intersection(CSG):
def __init__(self, v1, v2, color=None):
CSG.__init__(self, v1, v2, color=color)
self.bound = v1.bound.intersection(v2.bound)
def contains(self, p):
return self.v1.contains(p) and self.v2.contains(p)
def signed_distance_bound(self, p):
b1 = self.v1.signed_distance_bound(p)
b2 = self.v2.signed_distance_bound(p)
return b1 if b1 < b2 else b2

class Subtraction(CSG):
def __init__(self, v1, v2, color=None):
CSG.__init__(self, v1, v2, color=color)
self.bound = self.v1.bound
def contains(self, p):
return self.v1.contains(p) and not self.v2.contains(p)
def signed_distance_bound(self, p):
b1 = self.v1.signed_distance_bound(p)
b2 = -self.v2.signed_distance_bound(p)
return b1 if b1 < b2 else b2
Loading

0 comments on commit a06a4b1

Please sign in to comment.