Sometimes per-pixel lighting just can’t save you. That’s my final conclusion, but I thought the journey was interesting, and explored a few different ways of trying to figure out why your shaders aren’t doing what you expect.

This adventure started from a mistaken assumption. In a previous life, I worked a lot with texture mapping the world around a fixed viewpoint. In that setting, the actual geometry of the enclosing mesh doesn’t matter, as long as your texture is either pre-distorted (so that linear interpolation is geometrically correct) or sampled frequently enough (per-pixel computation to the rescue!). Somewhere along the line, I seem to have picked up the idea that as long as the outlines are correct, per-pixel computation will always let you reduce the geometry. That’s true for some extreme definitions of per-pixel computation (if you’re willing to do full per-pixel matrix computations, you can almost render an aligned quad and walk away); however, in the real world you have to be able to defer *some* computation to the vertex shader and take advantage of all that hardware linear interpolation.

Anyway, my goal is to draw cheap, correctly-lit cylinders, using as few triangles as possible. In the long-term, I want to use this technique for highly tessellated poly-lines, so there are serious upper bounds on the budget I can spend on each individual line segment. Thanks to my assumption above, I figured I should be able to get away with four points sampling a unit circle, and make up the difference in the pixel shader.

Per-pixel shading is necessary for this sort of task from the beginning; calculating color at each vertex just doesn’t cut it when you’re approximating non-linear shapes. Just for the record, though, here was the first result with per-vertex lighting of the “square cylinder”:

I figured that everything would be sorted out with per-pixel lighting. In practice, though, here were my results (followed by a high-tessellation version as the “right answer”):

These images look similar, but if you flip back and forth (I’ll try to get that working once I know WordPress a little better) you’ll see that the low-tessellation version has a band of brightness about a quarter of the way up the “cylinder”, while in the correct version, this is concentrated at the very bottom of the image.

As noted before, I was not expecting this. To get a handle on the problem, I did a quick and dirty plot of atan2(normalY, normalX) versus Y for the two cases:

Note that the error maxes out around 23 degrees. For a different visual look at the issue, I hacked up my pixel shader to plot the same atan values, and overlaid a high-tessellation version with the same shader:

This may not be the easiest visual to interpret, but a few points are clear. If you imagine a viewer looking at these models from the side (down the z axis, indicated by the green line), the “high angle” section of the square version will continue much further down the model than in the true cylinder. Interestingly, the model is closer to perfect for a viewer looking down at it diagonally, and would be truly perfect for a viewer along the axis, looking out at the enclosing cylinder (as I originally observed, but mistakenly generalized). The normals are effectively being projects from the enclosing cylinder onto the square, along the local normal axis. Since the local normal is constantly changing, there is no viewing position (other than the axis) from which this projection will not change the rendered image.

I tried one more experiment. In the pixel shader, I used the Y value (which was the viewing plane in all of these tests) and discarded the interpolated X. Instead, I recalculated X directly as sqrt(1-y^2). This is effectively projecting the normals along constant-Y values, rather than the (varying) local normal. This gives perfect results for the original viewing position (again, imagine viewing this model down the Z axis):

However, a viewer from above would see an even worse image than the original linear interpolation. This simple shader trick would work well only in my testbed setup, where the sightline was known and fixed.

It would be possible, presumably, to write a pixel shader which computed the viewing axis dynamically, back-projected into model space, then decomposed the model normal into basis vectors in the viewing plane, accepting one component and recalculating the other. This seems intuitively like it would work. The problem would be performing all those calculations per-pixel, or being sufficiently clever to separate the parts which could be interpolated without loss, and performing those in the vertex shader.

I guess the extreme along this path would be to billboard a quad, collapsing one end or the other to simulate the perspective division we’d be avoiding, then compute normals and depth values based on u,v coordinates within the quad. I tend to think that this would work, and possibly even be performant, but I don’t see how to extend to curves without an osculating plane. I may someday come back to this one, since plane curves are not an uncommon case, but for the moment I want a reasonable general solution. (I guess I’ll call this the obligatory exercise for the reader — work out the math for a pixel-perfect, single-quad rendering of cylinder segments which works for arbitrary view, model and world transformation matrices. Please?)

So, after chasing my tail for a while, I came around to the conclusion with which I started this post: sometimes there is just no substitute for more sample points. I’m going with octagonal “cylinders”, since multiples of 4 have nice silhouette properties when you happen to view them along an axis, and I think I can justify spending 16 triangles per line segment.

The next challenge is to work out the instancing code to essentially take the cartesian product of a stream of position / tangent values (from some calculated curve) against the fixed cylindrical model, without devolving to a batch per line segment. This should be fun.

## Leave a Reply