1: Performance Issues with ScreenSpaceLines3D
This entry is part 1 of a 12-part series on WPF 3D.
Problems with Lines
WPF 3D doesn't know how to draw lines.
Shocking as it may seem, WPF 3D considers triangles to be more fundamental than lines. Everything in 3D is a triangle. If you want to draw a line, you have to somehow do it by using triangles.
Actually, this is not all that shocking once you really think about it. The problem with a line is that it's too fundamental. Geometrically speaking, a line has zero width. If you asked WPF to draw a line, it would have nothing to draw. A triangle is the simplest bit of geometry that actually has any surface area.
We think that drawing a line should be simple. In 2D graphics, it is. But in 3D graphics, where the picture on your screen is a 2D projection of a 3D world which uses a completely different coordinate system, it's not.
But still, sometimes we want to draw something that we think of as a line. Maybe we want a wireframe view of our 3D scene. Maybe we just want to highlight the edges of a solid figure to make it easier to see. In practical terms, we know intuitively what we want when we think of "a line". Usually, we just want it to be one pixel wide on the screen.
But WPF 3D has a pedantic posture. In the WPF 3D coordinate system, a line is just a very thin rectangle. Or rather, it is two very thin triangles, put together to make a rectangle.
This is tedious for three reasons:
- We need those triangles to be facing the camera. A triangle has zero thickness. If we're looking at it sideways, then it's not visible.
- We need the width of those triangles to be set in 3D coordinates such that after the scene is projected onto the 2D display surface of our screen, the resulting "line" is one pixel wide.
- We need both of the above to stay true even when the scene is rotated or zoomed.
Some clever folks have produced a 3D Tools library which contains several classes designed to make WPF 3D programming easier. One of these classes is called ScreenSpaceLines3D. It solves all three of the problems above.
And it works beautifully. To create a line, all you have to do is instantiate this class and give it two endpoints (in 3D coordinates) and a thickness (in screen pixels). The resulting line looks just right, even if you rotate or zoom the view.
A perfect solution to the problem, right?
I've warned my readers before about using libraries that present an abstraction without really understanding what is going on under the hood. ScreenSpaceLines3D is a perfect example.
Stop and wonder how ScreenSpaceLines3D does its magic. The math isn't actually, the tricky part. Oh, it's tricky, but it's not that bad. It simply crawls up to find the dimensions of the Viewport3D in which it resides. Then it uses that information to calculate how wide something should be if it wants to be one pixel in the 2D projection. Tricky, but that's not the problem.
The question isn't so much how to do this calculation, but when. The line needs to be rescaled whenever anything about the projection changes.
The way ScreenSpaceLines3D handles this is to add a hook to CompositionTarget.Rendering. This handy feature of WPF allows us to register a callback function which gets called whenever something is being rendered. However, we need to remember that this gets called a LOT. Like 60 times per second, or so.
Here's the problem: ScreenSpaceLines3D adds one handler to CompositionTarget.Rendering for every instance. And this handler never gets removed.
So I noticed one day that Sawdust uses about 10-20% of the CPU even when it's not doing anything. In fact, the longer I use Sawdust in a single session, the more CPU it uses. If I bring it up and force it to draw several hundred pictures, it gets to the point where it is consuming 100% of the CPU even while idle.
Why? Because CompositionTarget.Rendering is being called 60 times per second, and every time it is called, it is rescaling every instance of ScreenSpaceLines3D that has ever existed.
Joel would say that ScreenSpaceLines3D is an abstraction that leaks. Literally.
I can think of several ways to try and fix this problem. For example, I could add code to ScreenSpaceLines3D to unregister the hook function when it is no longer needed.
Instead, I have decided that I don't want to use CompositionTarget.Rendering for this situation. I hacked my copy of ScreenSpaceLines3D.cs to remove it. Then I had lines that never got scaled properly. So I took the OnRender() function, made it public and changed its name to Rescale(). Then I added stuff to call Rescale() whenever I really need to.
This approach was a bit tedious, but it works pretty well. Lines get scaled, but the app uses no CPU when it's idle, and it doesn't get slower and slower the longer it runs.