Fall 2024
This assignment’s purpose is threefold. You will (1) reacquaint yourself with and apply some basic linear algebra concepts that we’ll be using heavily in this course; (2) get some basic familiarity with the Julia programming language; and (3) draw traingles!
Your goal in this assignment is to write a function to rasterize a triangle: in other words, take an ideal triangle repesented analytically as three vertices and draw it into a raster image by coloring the correct pixels.
Julia is installed on Linux and Windows in all of the CS the labs. You can you can also download and install Julia on your own computer from https://julialang.org/downloads/. The assignments in this course are tested using Julia 1.10.4, which matches the version on the lab machines.
To use the CS environment remotely, you can ssh
into a
lab machine (e.g. using labs-last)
and run the julia
REPL in a terminal. Viewing images
remotely is inconvenient - if you’re using bare SSH, I recommend setting
up an rsync
command to make it easy to copy result files
back to your local machine for inspection.
An alternative is to use an editor like VS Code that can give you the illusion of working locally on files on a remote host. Install VS Code and the Remote-SSH extension. I also recommend installing the Julia extension, which turns the editor into a pretty full-featured IDE. Once installed, you can find instructions for setting up the remote connection here.
Important: when connecting remotely, do not
connect to the Linux CoW (i.e., linux-??.cs.wwu.edu
). These
machines can’t handle the resource-intensive VS Code server, especially
when multiple people try to run it. The VS Code remote plugin doesn’t
make it through labs.cs
load balancer, so you’ll need to
pick a specific hostname to connect to. You can find a list of valid
hostnames here
(scroll down a bit to find a table titled “CSCI Lab Hosts”). You can
also manually ssh to labs.cs
, see which machine it sends
you to, and connect to that from with in VS Code.
Clone your repository and do the following:
$ cd path/to/repo/Raster # package name Raster will change per assignment
$ julia
At the Julia prompt, press ]
to switch to the
pkg
prompt:
julia> ]
(@v1.9) pkg>
Activate the Raster project, whose Project.toml
config
file is found in the current directory (.
).
(@v1.9) pkg> activate .
Activating project at `path/to/repo/Raster`
(Raster) pkg>
Notice the prompt changed to reflect the current environment. The
instantiate
command now installs the package dependencies
for this project:
(Raster) pkg> instantiate
Precompiling project...
(...)
(Raster) pkg>
If you get an error from instantiate
, make sure that
you’re in the correct directory; pwd()
can tell you where
you are. You should be in the Raster
directory that
contains Project.toml
(not the src
directory).
Now press backspace to get out of the pkg
prompt back to
the julia
prompt. We’ll now make the code in the
Raster
module accessible for use at the REPL:
(Raster) pkg> [backspace]
julia> using Raster
julia>
At this point, the project should be all set up. To verify that it is, run the following:
julia> hello_vec3(1, 2, 3)
Hello, [1.0, 2.0, 3.0]
julia>
Julia does not have the built-in ability to notice that your code has
changed and load the latest version, but fortunately there is a package
that called Revise.jl
that helps with this. To set this up,
fire up a new Julia REPL (I recommend doing this without activating the
Raster
environment so it’s installed globally) and add the
Revise
package:
$ julia
...
julia> ]
(@v1.5) pkg> add Revise
To use it, simply run using Revise
at the Julia prompt
before running using Raster
. Then, you can make repeated
calls to functions inside the Raster
module and your
changes will be noticed. It’s important to make sure that you run
using Revise
before running
using Raster
, as shown in the steps below.
If you wish to set Julia up to automatically
using Revise
every time you start Julia, you can add
using Revise
to your startup.jl
file (see here for help locating
it).
To fire up a REPL and begin work on the project, here’s what you need to do:
$ cd path/to/repo/Raster
$ julia --project
julia> using Revise
julia> using Raster
# now you can call your code at will:
julia> hello_vec3(1, 2, 3)
The --project
flag tells Julia to look in the current
directory for a Project.toml
file; when found, this
automatically activates the project’s environment. This works in place
of running ]activate .
after starting julia; either one
works.
The following is a very barebones tour of some Julia features and concepts that will be useful for this project. Many more comprehensive resources are available. Some that I’ve found useful include the following, sorted roughly in decreasing order of comprehensiveness:
You can also use documentation directly at the REPL by typing
?
, which switches you to a help search prompt. For
example:
julia> ?
help?> min
search: min minmax minimum minimum! argmin Main typemin findmin findmin!
min(x, y, ...)
Return the minimum of the arguments. See also the minimum function to take the
minimum element from a collection.
Examples
≡≡≡≡≡≡≡≡≡≡
julia> min(2, 5, 1)
1
One more bit of REPL prompt magic: a semicolon converts the prompt into a shell prompt:
julia> ;
shell> ls
Manifest.toml Project.toml src
julia>
Julia natively supports Multidimensional Arrays. An example of this
is canv
in the test_tri
function; this is a
ch
-by-cw
2D array of a special color type
(RGB{Float32}
, which represents a color in RGB format with
3 single-precision floats). Arrays such as canv
support a
bunch of really nice functionality that most other languages don’t have
built in:
canvas
as
canvas[i,j]
to get the pixel at the i
th row
and j
th column (both starting at (1,1) in the top left) of
the image. The first element of a Vec3
called
x
would be accessed using x[1]
. See here
for full details.for val in A
), or over each of its indices
(for i in eachindex(A)
). The eachindex thing even works for
multi-dimensional arrays! Details here.Vec2
and Vec3
At the top of the skeleton code, I created a couple helpful common
definitions we’ll use throughout the class. These will grow into a
separate module in later projects, but for now we simply define types
Vec2
and Vec3
, representing floating-point 2-
and 3-vectors. These are aliases for SArray
s, or static
arrays, which are mainly different from your standard Julia
Multidmensional arrays in that they have fixed size; everything above
still applies to them. You can create them
(v = Vec2(3, 4)
), index into them
(v[1] => 3
), and do elementwise arithmetic on them
(v + v => 6, 8
).
Assignment and unpacking - convenient stuff works with tuples and
arrays, including Vec2
and Vec3
:
a = 3, 4
b = Vec2(3, 4)
x, y = b
You can define functions in (at least) the following two ways:
The usual way, for longer functions:
function f(x, y)
= 2x + 3y # check that out - implicit multiplication, where it makes sense
z # last value is returned, but you can also add 'return' to be explicit
zend
The quick way, for short functions:
f(x) = x + h
This defines a function! I sometimes use this to make helpers inside
larger functions. Notice that helpers defined inside a function have
access to the outer function’s local variables; in this example,
h
is imagined to defined in the enclosing scope, so the
function can access it.
Some potentially useful builtin functions:
size
, min
, max
,
Int
Some potentially useful functions from LinearAlgebra
(comes standard installed; just add using LinearAlgebra
to
bring these into scope):
dot
, cross
Here’s one way to decide whether a point lies inside a triangle. Suppose a triangle is defined by three vertices, specified in counter-clockwise order (we will follow this convention in this class), a, b, c. Given a primitive routine that tells us whether a point lies to the left of a line defined by two points (cf. HW0 #3), we can simply use this three times. Specifically, p lies inside the triangle abc if p is to the left of all three lines ab, bc, and ca. Here’s an illustration of this - p1 is to the left of each vector and lies inside the triangle, while p2 is to the left of ab and bc but not ca, thus lies outside the triangle.
Multidimensional arrays in Julia are indexed as you would a matrix:
row index (i
) first, column index (j
) second,
with the top-left entry being (1, 1). If you treated these as (x,y) coordinates in a
Cartesian plane, we’d be in an odd situation where (0,0) would be above and to the left of the
top left pixel, the x axis
would point down, and the y
axis would point right. To get things looking like our familiar friendly
xy plane, we’ll need
to convert from i, j
to x, y. If we want
the origin in the bottom left corner, this is almost as simple as x = j, y = h − i
where h is the height of the
array. Two related wrinkles remain: first, the discrepancy between
1-indexing and 0-indexing. Second, if we imagine our array (image) as a
grid of square pixels, we’d ideally like integer array coordinates
(e.g., (h,1) to correspond to
the center of a pixel’s extent, which lives half a grid cell
offset from the integer x, y locations (e.g., (0.5,0.5)). The figure below shows a pixel
grid with both coordinate systems ovelaid.
When writing my code, I worked out formulas to convert from i to y and from j to x, wrapped each in a function, and then used that anytime I had array indices that needed to be converted to (x,y) coordinates. If you’re debugging, it’s always a good idea to check that you properly convert i, j for x, y when necessary, noticing in particular that the order of the coordinates flips (i, the first index relates to y, the second coordinate and similarly for x and j).
Create your assignment repository by accepting the invitation via the Github Classroom link provided in the A0 Assignment on Canvas, then clone a local copy.
When all is said and done, there’s not a ton of code to write, though you’ll want to have completed HW0 and thoroughly reviewed the Math section from above before you get started. I’ve broken the triangle drawing task down into three functions, each of which can use the previous.
line_side
returns positive, zero, or negative if a
point p
is left, on, or right of a line specified by two
points (Problem 3 from HW0 will be helpful here).point_in_triangle
returns whether a point
p
is in a given triangle.draw_tri
takes an image and fills points inside a given
triangle in with a given color.This is a small assignment, so I kept the project layout simple:
.
├── Manifest.toml
├── Project.toml
├── src
│ └── Raster.jl
As described above, navigate to the Raster
directory and
start the Julia REPL and run using Revise
and
using Raster
. At this point, you can test your functions
interactively (e.g.,
line_side(Vec2(0, 0), Vec2(0, 10), Vec2(4, 4))
should
return a negative number). The Vec
s and primary functions
are export
ed at the top of the file, meaning you don’t need
to qualify them with Raster.
to call them once you’ve run
using Raster
. To create colors (RGB
) you’ll
want to run using Images
, and likewise to save images
you’ll need to using FileIO
. I also included a single
barebones test function (test10_tri
) for triangle drawing
at the bottom of Raster.jl
; if your code agrees with mine,
running Raster.test10_tri()
should save out the following
stunningly beautiful image:
You may want to write a script to build up a set of repeatable tests.
I recommend simply creating a tests.jl
in the
Raster
directory with calls like the above, then running
include("tests.jl")
at the REPL. If you want to get fancy,
check out Julia’s unit testing
features; if you create a test/runtests.jl
with unit
tests you can run test
from the package prompt and it will
execute all the unit tests.
Once your triangle drawing function works, use it to draw something
cool! The artifact is due one day after the code deadline; I’ve included
an artifact
function you can fill in with code to produce
your artifact, but you don’t need to include this in your code
submission. This is also a good opportunity to play with a few more of
Julia’s features and constructs. Artifacts will be showcased and the
class will vote on their favorite. The winning artifact(s) will be shown
in class and receive a small amount of extra credit. To help with
inspiration, here’s my artifact:
580 students must complete at least one of the following extensions for full credit.
480 students may complete any of the following extensions for up to 5 points of extra credit.
You are also welcome to propose your own extensions, but run them by me first to confirm that you will get credit for them.
In all cases, you must thoroughly document the extensions you have completed in a readme.txt file included in your repository. Include a description of where to find the code that implements the extension, and instructions for how to run and test your code. Extensions with no tests or demo code will not receive full credit.
You’ve probably noticed that the edges of the triangles we’re drawing don’t look great, especially for small canvases and certain line angles. The technical term for the artifacts you’re seeing is jaggies. With an all-or-nothing approach to coloring pixels, there’s not much we can do about this, but we can make things look a lot better using what’s called anti-aliasing techniques. Two possible approaches to this are:
As mentioned in class, it’s usually the case in graphics that performance matters. One way to measure performance is to count FLOPs (floating-point operations). There’s a nice package GFlops.jl that can count the number of flops performed by a piece of code executed in the REPL. Here’s the output for my unoptimized implementation.
julia> a = Vec2(10, 10);
julia> b = Vec2(30, 10);
julia> c = Vec2(5, 40);
julia> color = RGB{Float32}(0.7, 0.7, 0.7);
julia> @count_ops img = Raster.test_tri(50, 50, a, b, c, color)
Flop Counter: 24154 flop
┌─────┬─────────┐
│ │ Float64 │
├─────┼─────────┤
│ add │ 6968 │
│ sub │ 9010 │
│ mul │ 8176 │
└─────┴─────────┘
julia>
Try to implement a version of draw_tri
that performs
fewer flops. How much can you beat mine by?
A basic implementation may encounter problems if two triangles that share an edge are drawn onto the same canvas, because a pixel whose center lies exactly on the shared edge may be colored zero, one, or two times depending on how ‘ties’ (i.e., points directly on the line) are handled. This may be further complicated by slight floating-point errors. Develop test cases that highlight this problem, then try to find a way to fix it.
Our triangles are solid colored. Devise and implement a scheme to enable multi-colored triangles: for example, a caller might be allowed to specify a different color for each vertex (or for each edge?) and each pixel gets a color value interpolated from these.
Add and commit test10.png
, the output of
test10_tri()
, to your repository. Submit your code by
pushing your final changes to Github before the deadline, then submit
the A0 Survey on Canvas.
To submit your artifact, upload your PNG or GIF (for animations, if you chose to make one) file to the applicable A0 assignment on Canvas. Note the artifact deadline is one day later than the code deadline, so you have some extra time to get creative.
Here are some resources that you may find helpful in completing this project.
Points are earned for correctness and deducted for defficiencies in clarity or efficiency.
line_side
- 10 pointspoint_in_triangle
- 10 pointsdraw_tri
- 10 points