Homework 3: Shaded renderer

In this assignment you will turn your wireframe renderer into a shaded convex polygon renderer.

Homework 3 is due on Tuesday Oct. 26 at 11:59 pm

Scene Description

We will add one new top-level block type and two Separator-level blocks to the scene description language:

PointLight {
   location x y z
   color r g b
}

This creates a point light at <x,y,z> with the given color. There can be zero or more lights. PointLight is a top-level block, just like PerspectiveCamera and Separator.

Either or both of a light's values can be unspecified. location must default to <0,0,1> and color defaults to <1,1,1>.


Material {
   ambientColor 0.2 0.2 0.2
   diffuseColor 0.8 0.8 0.8
   specularColor 0 0 0
   shininess 0.2
}

These blocks go inside Separators and specify the colors to be used in lighting calculations. These behave exactly like the ones from hw0. Each Separator will have one Material.

Unspecified fields must default to the values above. Color values must be clamped to the range [0.0, 1.0].


Normal {
   vector [ x0 y0 z0,
            x1 y1 z1,
            .
            .
            xm ym zm
   ] # end of "vector" command
}

These blocks go inside Separators as well and specify a list of normal vectors. Each Separator will have one Normal block.


Finally, we need to modify IndexedFaceSet to specify a normal at each vertex:

IndexedFaceSet {
   coordIndex [ ... ] # as in HW 1
   normalIndex [ face0norm0, face0norm1, ... -1,
                 face1norm0, face1norm1, ... -1,
                 ...
                 faceNnorm0, faceNnorm1, ... -1
   ]
 }

faceXnormY is an integer index into the Normal list, just like the numbers in coordIndex are indices into the Coordinate3 list.

Transforming Normals

Normals are transformed by transpose of inverse of O matrix, without translations. That is, if my separator has T, R, and S, I will translate the normal by doing N_new = Transpose(Inverse(R * S)) * N. This yields a normal in world space.

Discarding Output

Backface Culling

For a polygon with vertices (v0, v1, v2), let (v1', v2', v3') be each v1, v2, and v3 transformed into NDC. Then let the normal n = (v2' - v1') x (v0' - v1'). If n_z > 0 (if the z component is at all positive), draw the polygon. Note that these normals are entirely distinct from the vertex normals parsed in.

The Z buffer

The Z buffer (aka the "zed" buffer) does all the polygon depth sorting and intersection/polygon-polygon-clipping for you. All you must do is keep track of the Z-value of each x,y pixel you draw in a separate buffer. When you desire to draw a pixel, first check that the depth of the one you want to draw is less. If so, draw it and change the Z buffer to the new lower value. Of course, initialize each element of the Z buffer to a really big value (INFINITY, if you have access to it, will work; otherwise, come up with some huge number).

Lighting

The most important part of this homework is the lighting function. The lighting function is given a point in space, a normal, material properties, and the camera/lighting positions, and computes the correct color for that point in a polygon. This portion of the program tends to require a lot of care, so it is important that you implement the algorithm correctly.

Lighting Function

5 things needed by the Lighting Function
----------------------------------------
Changes from call to call
  1 pos     point in world space*
  2 norm    normal in world space*

Same from polygon to polygon
  3 material* for that polygon (that Separator, actually)

Same for all shading calls (unless camera/lights move)
  4 all the lights*    in world space
  5 camera position    in world space

NOTES: 1: No frustrum/camera position applied,
          only object Transform blocks (obj->world space)
       2: Normals are transformed by transpose of inverse
          of O matrix, without translations. That is, if 
          my separator has T, R, and S, I will translate
          the normal by doing N_new = Transpose(Inverse(R * S)) * N.
          See the webpage on Homogeneous Coordinates and Transformation
          Matrices. You must always remember to normalize your normal
          (hence the term normalize).
       3: Material consists of diffuse, ambient, and
          specular/shininess exponent
       4: Each light has x,y,z (position) and r,g,b (intensity)

Lighting function details

-- some helper functions
zeroclip(X) = [(x when x > 0.0 else 0.0) for x in X]
oneclip(X)  = [(x when x < 1.0 else 1.0) for x in X]
unit(x) = x / |x| when |x| != 0.0 else 0.0
    
-- some pseudo-code to do the lighting
lightfunc(n, v, material, lights, camerapos) : (r,g,b) =
do {

    -- let n = surface normal (nx,ny,nz)
    -- let v = point in space (x,y,z)
    -- let lights = [light0, light1, ... ]
    -- let camerapos = (x,y,z)

    scolor = material.specularcolor -- (r,g,b)
    dcolor = material.diffusecolor  -- (r,g,b)
    acolor = material.ambientcolor  -- (r,g,b)
    shiny =  material.shininess     -- (a scalar, an exponent >= 0)
    
    -- start off the diffuse and specular
    -- at pitch black
    diffuse = [0.0, 0.0, 0.0]
    specular = [0.0, 0.0, 0.0]
    -- copy the ambient color (for the eyelight ex/cred
    -- code, you can change it here to rely on distance
    -- from the camera)
    ambient = acolor

    for l in lights
    do {
        -- get the light position and color from the light
        -- let lx = light position (x,y,z)
        -- let lc = light color (r,g,b)
        lx, lc = l

        -- first calculate the addition this light makes
        -- to the diffuse part
        ddiffuse = zeroclip(lc * (n . unit(lx - v))
        -- accumulate that
        diffuse += ddiffuse

        -- calculate the specular exponent
        k = zeroclip(n . unit(unit(camerapos - v) + unit(lx - v)))
        -- calculate the addition to the specular highlight
        -- k^shiny is a scalar, lc is (r,g,b)
        dspecular = zeroclip(k^shiny * lc)
        -- acumulate that
        specular += dspecular
    }
    -- after working on all the lights, clamp the diffuse value to 1
    d = oneclip(diffuse)
    -- note that d,dcolor,specular and scolor are all (r,g,b).
    -- * here represents component-wise multiplication
    rgb = oneclip(ambient + d*dcolor + specular*scolor)
    return rgb
}

Shading Models

Overview

For the different shading models, you use the same lighting function which is called in a different manner; there are three different lighting algorithms.

Lighting Algorithms

Algorithm   What It Does
----------  ------------
Flat        takes the average position and normal of the vertices
            => 1 call to lighting function => 1 rgb
Gouraud     takes n vertex positions, n vertex normals
            => n calls to lighting function => n rgb's
            (n = number of vertices on the polygon)
Phong       takes m positions, m normals
            => m calls to lighting function => m rgb's
            (m =  number of pixels on the drawn polygon)

The algorithm differs in that Flat shading averages the normals and vertex positions to get a single lighting value for the entire polygon (or just uses one vertex's information):

Flat-shaded cube

whereas Gouraud shading calculates RGB values for each vertex on the polygon and then linearly interpolates colors:

Gouraud-shaded cube

and Phong shading linearly interpolates vertex normals across the face and then calls the lighting function to get the RGB value for each pixel in the polygon:

Phong-shaded cube

Shading

Bill's code

Your program should provide flat, Gouraud, and Phong shading as discussed above. C++ and Python code to draw shaded polygons is provided, along with a README file. NOTE: some students have reported that the Python code is broken. It should be fixed now.

raster.cpp provides the following struct:

struct vertex {
    float *data;
    int numData;
}

and the following functions:

void initDraw(float xMin, float xMax, int yMin, int yMax, int xr, int yr);
void raster(vertex verts[3], void (*drawPixel)(int, int, float *));

To use it, you would, for example, define

void mySetPixelFunction(int x, int y, float *data);

Then, you would call initDraw once, then execute something like the following code:

vertex verts[3];
for (int i = 0; i < 3; i++) {
    verts[i].data = new float[numberOfDataPointsPerVertex];
    verts[i].numData = numberOfDataPointsPerVertex];
    verts[i].data[0] = xPosition[i];
    verts[i].data[1] = yPosition[i];
    verts[i].data[2] = otherData1[i];
    ...
}
raster(verts, mySetPixelFunction);

Feel free to modify raster.cpp and raster.h (or raster.py if that is what you use).

NOTE: the provided rasterization code does not correctly handle adjacent triangle borders. If two triangles share an edge, the second triangle drawn will overwrite that edge. This is fine as-is for this lab, but you should be aware of it. Also, you may correct this for extra credit. Relevant information is found in the pdf linked below, and in the book, pgs 63-66 (though there is a bug in the pseudocode in the book).

See the resources page for more code that also includes clipping. You will find both C++ and Python versions.

If you're trying to implement this algorithm from scratch, or you want to understand Bill's code above, you may find the notes on barycentric coordinates and triangle rasterization helpful. That document will also point you towards the relevant pages in the course text.

You may also use Martin's rasterization code if you wish, as it has a simpler interface.

Summary

This diagram gives a pictoral overview of the path your data should follow through your program. The branch, which goes to either the lighting function or the rasterizer has to do with the different shading models. In flat or gouraud shading, the lighting function is called first, and the resultant colors are passed through the rasterizer. In phong, on the other hand, the data for the lighting function is passed through the rasterizer before it is actually sent to the lighting function. Thus, the diagram needs a branch.

Program flow chart

If you are having trouble with your lighting function, a few common mistakes to check for are:

We strongly suggest building this assignment in pieces, and testing each piece independently, as each part of the program can cause anomalous behavior in all of the later parts.

What your program should do

Your program should use the following syntax:

shaded n xRes yRes < iv-file

n determines what shading mode to use:

Write a PPM to stdout just like in the previous two labs.

Extra Credit

For .5 points of extra credit, add a -eyelight parameter that adds a dim light near the camera. This light should be about 10% or 20% of the intensity of the other lights in the scene. Remember to document this option in the README file.

For .5 points of extra credit, correctly handle adjacent triangle borders, as explained in the note above.

Testing

Test files are available in the data directory. Note that the image for sphere.iv using flat shading is incorrect (it should show a white and a blue light, not two white lights). You should also develop your own test cases.

Include a README file with your code, saying what you did, how to compile it, and anything else you think we need to know.