Optimizing Animations Using Direct2D

Sci-Fi Text with fade

(click image for larger view)

(video from Tom's Blog

In my previous post I demonstrated rendering text in the style of the Star Wars opening credits. The demo works and the animation is smooth (on my machine at least), but the CPU consumption is much higher than necessary. Even though Direct2D's text rendering is hardware accelerated, it still requires a lot of chatter between the CPU and the GPU. What's really wasteful in the sample is that we are rendering the same content to the same surface once per frame. The optimization is as simple as adding a boolean that keeps track of whether or not the intermediate surface has been populated. I leave this as an exercise to the reader.

Things get more complicated if you need the intermediate surface content to change, however optimizations are still possible. Suppose you want the text to fade into black as it gets far away. One way you could do this is by modifying the pixel shader, but I'm going to show how you can do this using the Direct2D API.

The first step is to change the brush used for text from solid color to linear gradient:

 D2D1_GRADIENT_STOP gradientStops[] =
{
    { 0.0f, D2D1::ColorF(D2D1::ColorF::Yellow) },
    { 1.0f, D2D1::ColorF(D2D1::ColorF::Black) }
};

ID2D1GradientStopCollectionPtr spGradientStopCollection;
IFR(m_spRT->CreateGradientStopCollection(
    gradientStops,  
    ARRAYSIZE(gradientStops),
    D2D1_GAMMA_2_2,
    D2D1_EXTEND_MODE_CLAMP,
    &spGradientStopCollection));

IFR(m_spRT->CreateLinearGradientBrush(
    D2D1::LinearGradientBrushProperties(D2D1::Point2F(0, 0), D2D1::Point2F(0, -2048)),
    D2D1::BrushProperties(),
    spGradientStopCollection,
    &m_spTextBrush
    ));

This creates a linear gradient brush with two gradient stops, one yellow and one black. Since I want the text to be completely yellow when you first see it, I've defined y=0 to be yellow and gradually fade to black as y gets more and more negative. D2D1_EXTEND_MODE_CLAMP means that I want the color beyond the gradient stop end points to be equal to the color at the gradient stop end points. For a funky effect you could change this to be D2D1_EXTEND_MODE_MIRROR.

D2D1_GAMMA_2_2 refers to the gamma at which colors are interpolated. If you haven't heard of gamma before I recommend you check out Charles Poynton's Gamma FAQ. For the purpose of this article it is sufficient to know that using D2D1_GAMMA_2_2 results in a non-linear interpolation of colors. The other option, D2D1_GAMMA_1_1, results in linear interpolation of colors. Since light intensity decreases proportionally to the square of the distance from the light source, we want the interpolation to be non-linear. Strictly speaking D2D1_GAMMA_2_2 is not the correct non-linear curve, but it is much better than D2D1_GAMMA_1_1, and it is good enough for our example here. For more exact results you can approximate quadratic interpolation using more gradient stops.

Perf Tip: When rendering using hardware acceleration, each call to CreateGradientStopCollection will result in a new D3D texture being created. Since texture creation/destruction is expensive, you should try to avoid calling CreateGradientStopCollection often. For example, my sample does not call CreateGradientStopCollection every frame, but only when the render target is resized.

Next we adjust the transform on the linear gradient brush by calling SetTransform each frame:

 m_spTextBrush->SetTransform(D2D1::Matrix3x2F::Translation(0, (4096 / 16) * t));

Think of the brush as a large piece of paper and the different rendering primitives (text, geometry, rectangle, etc.) as being stencils. When we adjust the brush transform, we are sliding the large piece of paper underneath the stencil, but we are not changing the position of the stencil itself. So if we create a linear gradient brush that gradually changes from yellow to black, we can make the text fade from yellow to black by changing the brush transform. If you recall from the previous post, t is the variable we adjust to slide the surface away towards infinite in the z direction. Here we are adjusting the transform by t multiplied by a factor of (4096 / 16), which is the ratio of the surface height to the length of its projection in 3D space. The net result is the illusion that the brush maintains a constant position as we slide the surface away.

So now we've made the text fade but we had to disable our optimization in order to do so. We have to change the contents of the Direct2D render target once per frame so we can't just avoid drawing to it once it's populated. So what can we do to gain back performance? Remember that we are drawing the same text over and over again - the only thing that changes is the brush. We'd like to cache the results of text rendering and fill it with a different brush each frame. You can do this in Direct2D using an alpha-only compatible render target and the FillOpacityMask API:

 D2D1_PIXEL_FORMAT alphaOnlyFormat = D2D1::PixelFormat(DXGI_FORMAT_A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED);
IFR(m_spRT->CreateCompatibleRenderTarget(
    NULL,
    NULL,
    &alphaOnlyFormat,
    D2D1_COMPATIBLE_RENDER_TARGET_OPTIONS_NONE,
    &m_spOpacityRT));

...

m_spRT->SetAntialiasMode(D2D1_ANTIALIAS_MODE_ALIASED);

ID2D1BitmapPtr spBitmap;
m_spOpacityRT->GetBitmap(&spBitmap);

m_spRT->FillOpacityMask(
    spBitmap,
    m_spTextBrush,
    D2D1::RectF(0, 0, rtSize.width, rtSize.height),
    D2D1::RectF(0, 0, rtSize.width, rtSize.height),
    D2D1_GAMMA_1_0);

This creates a render target that is compatible with m_spRT in the sense that all resources (bitmaps, brushes, etc.) that can be used with m_spRT can also be used with our new render target. We are only using this new render target for alpha content, so we create it using DXGI_FORMAT_A8_UNORM.

Perf Tip: Use DXGI_FORMAT_A8_UNORM when you are only using the alpha channel. Non-alpha-only formats will work, but they use up to 4 times the memory.

Instead of drawing text directly into m_spRT as we did previously, this time we'll draw the text into the compatible render target. Then we apply our linear gradient brush using the contents of the compatible render target as an opacity mask. Since we are always drawing the same text into the compatible compatible render target we only need to do it once (just like when we were using an ID2D1SolidColorBrush). FillOpacityMask does not support antialiased rendering, so we have to set the render target to use aliased rendering.

Sanity Tip: If you are getting D2DERR_WRONG_STATE after calling FillOpacityMask, check that the render target is in aliased rendering mode.

You'll see that D2D1_GAMMA has appeared again. This time we are using D2D1_GAMMA_1_0. Direct2D resources all use 2.2 gamma internally. For maximum correctness, colors should be converted to 1.0 gamma before blending, but blending in 2.2 gamma is usually acceptable. Since color conversion is expensive, Direct2D normally skips the color conversion step, except when rendering text (Text quality is both very important and very sensitive to gamma correctness). Since opacity masks may be used for either text or non-text content (or both!) Direct2D lets you choose whether on not to do the color conversion. So to make a long story short, D2D1_GAMMA_1_0 is slower but higher quality. D2D1_GAMMA_2_2 is faster but lower quality. Since I'm using the opacity mask for text, I chose to use D2D1_GAMMA_1_0.

I've attached the Visual Studio project. See my previous post for tips if you have problems compiling.

SciFiText-WithFade.zip