Monday, July 19, 2010

Geometry Shader Silhouettes without Adjacency Information

Rendering silhouette edges is a classic problem in NPR. It has other uses to - for example, in terrain rendering, it can convey quite a bit about where ridge lines are:

Of course, the above comparison is not very fair. The image on the left is just shaded by height (no lighting) which can hide terrain features, especially for horizon views. Regardless, silhouettes are cool and most graphics developers are familiar with the standard geometry shader approach based on adjacency information (if not see Inking the Cube: Edge Detection with Direct3D 10 or Single Pass GPU Stylized Edges). What I'd like to briefly share with you is a geometry shader approach that does not require adjacency info, which means you won't need one index buffer with adjacency info and another without it!

I should mention that Deron came up with this and I am sharing it so he can keep busy on the book! The idea is simple - use the geometry shader to do a procedural geometry technique called triangle fattening (see Image Precision Silhouette Edges) in screen space. In the first pass, the model is rendered normally with backface culling. In the second pass, it is rendered with front face culling and a geometry shader that transforms the triangle to screen space and expands each edge by a given number of pixels. To eliminate elongated corners, the three input vertices are turned into six as shown here:

The original triangle is gray and the expanded "triangle" is black. The result is a uniform width silhouette. This method is similar to a fixed function algorithm, which renders the second pass in wireframe mode using a wide line. But in OpenGL 3, line widths greater than one were deprecated.

In our method, the geometry shader outputs six vertices for all backfacing triangles. Most of those fragments should get thrown away by early z since the front facing triangles were laid down in the first pass. The geometry shader could output more vertices to reduce the rasterization load but we found this to slow things down quite a bit. Performance is commonly bound by geometry shader output and this algorithm will output a lot more triangles than the standard geometry shader algorithm that only outputs triangles for edges on the silhouette. We think this algorithm is still worth mentioning since it is so easy to integrate into a rendering pipeline, especially if you have the shader source. Here: SilhouetteGS.glsl.


  1. I think your geometry shader might be slightly too complex. You can compare it to the wide line shader that I finished last weekend at

    I transform the line axis to screenspace and rotate it there to get side vector. Then I transform these offsets back to clip space. I suspect you should be able to do the same thing with triangle, basically treating it a bit like three lines.

  2. Hi Timo,

    I won't deny that our shader is complicated, ha.

    The shader expands out a triangle a defined number of pixels which requires it to create 3 additional vertices to maintain that expanded border. Two more vertices are needed if the triangle is clipped. So, either 6 or 8 vertices are output.

    My previous method took the 3 edges of the triangle and created 3 lines instead of expanding the triangle. That required 15 vertices, which was 4 vertices plus 1 for the end cap per line.

    I found that the triangle expansion method ran much faster than the 3 lines method. Both methods are definitely bound by the number of vertices that the geometry shader outputs, and the 3 lines method typically outputs 2.5x more.

    I have a question regarding your clipping code:

    float t0 = start.z + start.w;
    float t1 = end.z + end.w;
    if(t0 < 0.0)
    if(t1 < 0.0)
    start = mix(start, end, (0 - t0) / (t1 - t0));
    if(t1 < 0.0)
    end = mix(start, end, (0 - t0) / (t1 - t0));

    If t0 and t1 are positive, no clipping is required. I had similar code, but was running into an issue when one vertex was in front of the viewer beyond the near plane, while the other vertex was behind the viewer beyond the negative near plane. In that case, both t0 and t1 are positive. In our original code, like yours, the line would not be clipped, but should be. Have you seen this problem?

  3. Sorry for the late reply. No, I have not noticed any issues since I got it working.

    I am not exactly sure which case you mean. For me t is a signed distance to the near plane. If start and end are on different sides of the near plane, the signs can not match, and clipping is needed. On the other hand if the signs do match then manual clipping is not needed.

    What I found out is that it is essential to do each step in the correct space, so if you do things differently that may cause issues.


Note: Only a member of this blog may post a comment.