Last modified 14 May 2004 by e-mail me

Inking Tutorial

Nowadays many animated television shows and films use 3D computer images, which are made to fit seamlessly into the surrounding 2D cartoonery through some clever effects. Well, they're not really that clever. All they have to do is paint the edges of each rendered model with black lines, to simulate the lines in an ink drawing. On modern 3D graphics hardware this can be done very efficiently using vertex and pixel shaders. This page presents a more antiquated, and less effective, method.

The algorithm here makes two passes. First we render the 3D model as a fat black wireframe. Then render the model again, normally, on top of the wireframe. I've been wanting to include this functionality in my SMea Kit, but it has resisted all attempts at abstraction. (For efficiency, the user will want to micromanage lighting, texturing, etc.) The results are like this:

Raw
Image
Inked
Only
Inked and
Antialiased
rough no ink a rough ink a smooth ink a
rough no ink b rough ink b smooth ink b

Click on any image to enlarge it. Any dithering you see is an artifact of the GIF encoding; it is not present in my program. The antialiasing is provided by the SMea Kit. The tree and figure models are pretty bad, but only because I'm lazy.

See, if you simply draw the scene in black wireframe and then again in colored polygons, the two will collide in the depth buffer and precision errors will mangle your image. So my inking code uses OpenGL's polygon offset facility to pull the colored polygons imperceptibly closer to the viewer than they should be, which lets them be painted over the black wireframe.

The inked images suffer from prevalent defects, including numerous black specks, which arise because I am not pulling the polygons enough. Meanwhile, you can also see that intersecting solids sometimes show through each other a bit, as in the tree branch in the top example and the figure's hand in the bottom example. This is happening because the polygons are not pulled enough. So this is a balancing act; the pictures above represent my consistently obtainable results. You might try to improve upon them by perturbing the arguments to glPolygonOffset() in the code below. If you do better, please let me know.

void Ink(id drawer, SEL draw, GLint width)
{
    // take the ink width into account:
    glLineWidth(width);
    glPolygonOffset(-((GLfloat)width / 2.0), -1.0);
    // draw the filled polygons pulled toward the camera:
    glEnable(GL_POLYGON_OFFSET_FILL);
    [drawer performSelector:draw];
    glDisable(GL_POLYGON_OFFSET_FILL);
    // draw the edges in black:
    glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
    glColor3f(0.0, 0.0, 0.0);
    glDisable(GL_COLOR_MATERIAL);
    // disable all enabled lights here, but don't disable lighting itself:
    glDisable(GL_LIGHT0);
    [drawer performSelector:draw];
    // reenable the lights:
    glEnable(GL_LIGHT0);
    glEnable(GL_COLOR_MATERIAL);
    glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
}