Virtual Developer Workshop: Containerized Development with Docker
Exploring the GDI+ Warping Capabilities
GDI+, the new Windows graphics API, has a very interesting facility to 'warp' a graphics path. It lets you deform a graphic element, almost as though it were printed on a piece of very flexible rubber. Basically, it maps a rectangle to an arbitrary quadrilateral, moving and resizing every part of the path with it. Potentially, it is a very powerful tool.
After exploring the GraphicsPath::Warp() member function in some depth, however, I've become less enthusiastic, to say the least. Unless I'm seriously mistaken, this function is quite buggy. Luckily, I found two cures for the problems I encountered.
The font is 'Georgia' bold, one of the Windows XP system fonts. The light blue rectangle is the bounding box. I took this as the source rectangle for the warping operation.
Next, I defined the four points of the destination quadrilateral. Then, I applied the Warp() member function to my test path, like so:
pPath->Warp(m_WarpPoints, 4, m_TestRect, NULL, WarpModePerspective, flatness);
GDI+ supports two kinds of warp: perspective and bilinear. Perspective warping, the default mode, gave me this result:
Although not perfect, as I'll show further on, it looks acceptable. The destination quadrilateral is shown in light green.
Problem #1: Bilinear warping
Encouraged, I immediately tried warping in bilinear mode. Here is what I got:
I didn't know whether to laugh or to cry about this hilarious output. This isn't warping; this looks more like crumpling up.
I was able to create quite a few variations on this Weird Warp and also encountered other anomalies. If you run the demo app, you might notice that GDI+ takes a suspiciously long time to come up with its interpretation of bilinear warp. Don't be surprised if it seems to hang for a while, or even crashes.
After reading the few dozen words about GraphicsPath::Warp() in the GDI+ documentation again and again, to make sure I didn't overlook an initialization routine or something like that, I dare to conclude that the function is buggy to an unacceptable degree.
After analyzing the output, it seems clear that in bilinear mode, Warp() does not recognize subpaths. It doesn't see where the letters start or end, and mixes everything up. Knowing this, it's rather easy to split the path in its subpaths, warp these individually, and paste the warped subpaths together. You then get a result as it's meant to be:
This looks like genuine bilinear warping.
Problem #2: non-working flatness
The dramatically misformed result in bilinear mode isn't the only problem with GraphicsPath::Warp(). Before performing the actual warp, the function 'flattens' the graphics path, so that all curved parts are converted to lines. Warp() has a parameter, flatness, that influences the number of lines. At least, that's the story as told in the documentation. In reality, flatness doesn't influence anything at all. The parameter is completely disregarded, both in perspective and in bilinear mode.
On my system, the warped path in perspective mode above always has exactly 183 path points, regardless of the flatness value. I tried values from 0.01, which is absurdly low, to 4.0, which is very high. In bilinear mode, I get similar results. Then, the warped path always has 203 points. The number of path points is retrieved by the GraphicsPath::GetPointCount() method.
If we want to change the flatness, we have to do so explicitly. We should 'preflatten' the path. We can do this by calling the GraphicsPath::Flatten() member function before calling Warp(). The following table of the number of points shows that this really works. (Note that warping in perspective mode leaves the number of points unchanged; bilinear warping adds a few points—this, by the way, is a problem in itself.)
|Flatness||After flattening||After bilinear warping|
Although subtle, the visual effect is apparent, as can be seen in the following details after warping in perspective mode:
The image on the left is the normal output of Warp(); the one on the right is the output after 'preflattening' with a flatness of 0.1. Note that the curves in the right picture are smoother.
Studying the above table, it seems that the warp function always flattens the path with the same flatness variable of around 2.2. This is a lot bigger than the default value of FlatnessDefault, which is 0.25. The results are therefore much more crude. It means that Warp() almost always yields a far worse result than you might expect after reading the documentation.
After diagnosing the problems with GraphicsPath::Warp(), the solution is relatively simple. I wrote a small global function, called CuredWarp(). It wraps the splitting in subpaths to cure Problem #1, and the 'preflattening' to correct Problem #2.
CuredWarp() has the following signature:
Status CuredWarp(GraphicsPath& path, const PointF *destPoints, INT count, const RectF &srcRect, const Matrix *matrix = NULL, WarpMode warpMode = WarpModePerspective, REAL flatness = FlatnessDefault);
The first parameter, path, is a reference to the GraphicsPath to be warped. The other six parameters, and the return value, are identical to those of the GraphicsPath::Warp() method—the difference being, of course, that they really work.
An even better solution: QWarper
Even in its cured form, I find the GraphicsPath::Warp() method less than perfect. The main problem is the built-in flattening of the path. In my opinion, this is completely unnecessary. It merely accounts for a heavy overhead, because flattening a path is quite an expensive function. A warping function should only move the path points to another location, and not alter the structure of the path. A curve segment should stay a curved segment after warping.
The fact that the bilinear variant of the function returns more points than you put into it is especially disturbing. Apparently, it somehow makes up some new points instead of just moving the given points.
So I decided to do it myself. Entering things like 'bilinear warp algorithm' in Google gave me hundreds of hits, but I couldn't find any off-the-shelf algorithm. The only useful bits of information I found were the so-called warping functions for bilinear and perspective warping (and for a few other warping modes, as well). I used these to devise my own algorithm. Because it has been a while since I was in math class, and my calculus is a bit rusty, this was no easy exercise. It involved tasks such as solving a set of eight equations with eight unknowns. But, the very rewarding result is here: the QWarper class.
With QWarper, you can warp a GDI+ GraphicsPath without using the latter's Warp() method at all. The three advantages are:
- It doesn't have the subpath bug in bilinear mode.
- It doesn't flatten the path.
- It is much faster.
I hadn't expected the last advantage. But, QWarper really is considerably faster than GraphicsPath::Warp(). I suppose the main reason is the lack of the unnecessary flattening step. I only tested it informally, by warping a (non-subpath) path of intermediate complexity 30,000 times. The results speak for themselves:
|GraphicsPath::Warp(), perspective||21 secs|
|GraphicsPath::Warp(), bilinear||44 secs|
|QWarper, perspective||7 secs|
|QWarper, bilinear||6 secs|
QWarper does some precalculating in its constructor. To use the class, create an instance of QWarper. The constructor parameters define the warping operation. They are identical to the parameters of the GraphicsPath::Warp() method and the CuredWarp() function, and speak for themselves. Here is the signature of the constructor:
QWarper(const PointF * destPoints, const RectF& srcRect, WarpMode mode = WarpModePerspective);
After constructing a QWarper, use one of its two member functions to perform the warp:
Status WarpPoints(PointF * points, INT count); Status WarpPath(GraphicsPath& path);
Note that QWarper does not support the 'three-point warping' of GraphicsPath::Warp(), which restricts you to parallelograms as the destination quadrilateral. If you want this kind of warp, you'll have to calculate the fourth point explicitly before construction.
Likewise, QWarper does not support a matrix parameter to apply an extra affine transformation along with the warp.
The QWarper class and the global function CuredWarp() are part of a very small demonstration project, called WeirdWarp. It does nothing more than display what's described in this article. As written before, don't be surprised if it seems to hang for a while—it's all due to bugs in GraphicsPath::Warp().
Use QWarper or CuredWarp() whenever you're inclined to use GraphicsPath::Warp() to achieve acceptable results.
Note: Your system must support GDI+, which currently only XP does natively. However, other Windows versions can be upgraded. Also, VC++ 6.0 comes without the GDI+ headers. You may obtain them by downloading the Windows Platform SDK. The GDI+ headers are included with VC++ 7.0.
DownloadsDownload demo project and source - 48 Kb