65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Direct2D & DirectWrite 渲染文本

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (39投票s)

2012年9月30日

CPOL

8分钟阅读

viewsIcon

112039

downloadIcon

2845

Direct2D, DirectWrite, Windows API, C++, std::shared_ptr 等

更新

C++ Windows 开发,使用菜单[^] 的代码包含了库的重大新功能和更新。

引言

这是系列文章的第一篇,旨在说明一种通过 C++11 标准实现的更安全的 C++ 开发方法,同时我们的代码直接构建在基于 Windows C 和 COM 的 API 之上。

在下一篇文章中,C++ Windows 开发,使用菜单[^],我们将探讨用于创建和处理菜单的 Windows API,并着眼于 C++11 如何实现更安全的编程模型。

本文更多的是关于使用 std::shared_ptr<> 和其他智能指针所实现的编程风格,而不是关于 Direct2DDirectWrite。该库包含一组包装 Direct2DDirectWrite 功能的类,并添加了一些重要功能

  • 错误被转换为异常
  • COM 接口生命周期的透明管理

该演示应用程序实现了与 DirectWrite SDK 示例之一相同的功能,并且代码量显著减少。

现在,我们这些开发显示 3D 内容的应用程序的人习惯于使用 GPU 的强大功能。虽然使用 Direct3D 显示 2D 内容当然是可能的,但我们大多数人不会用它来渲染几行文本,或任何其他可以使用 GDI 或 GDI+ 轻松实现的内容。

从 Windows Vista Service Pack 2 和 Windows 7 开始,我们现在有了一组新的 API,可以促进使用 GPU 进行 2D 渲染,称为 Direct2D。同时,微软引入了另一个新 API DirectWrite,支持文本渲染、分辨率无关的轮廓字体以及完整的 Unicode 文本和布局支持。

虽然 Direct2DDirectWrite 的 SDK 中包含的示例提供了我们开始使用新 API 所需的基础知识,但它们有些繁琐,我希望您会发现我使用的方法更容易理解。

目前代码处于非常早期的阶段,这意味着肯定有一些粗糙之处和未完成的部分,但从设计角度来看,它开始变得有趣了。

您是不是希望您的 wWinMain 看起来像这样

int APIENTRY wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
    auto application = std::make_shared<Application>();
    auto form = std::make_shared<MyForm>();
    auto result = application->Run(form);

    return result;
}

该代码依赖于 Boost C++ 库,可以从 https://boost.ac.cn/[^] 下载,因此您需要下载并构建它,然后使用与您的安装匹配的包含和库路径更新提供的项目。

代码演练

我确信您注意到了上面的 auto form = std::make_shared<MyForm>() 语句。

现在,std::make_shared<MyForm>() 是一种创建指向 MyForm 类型对象的 std::share_ptr<MyForm> 智能指针的智能方法;因为它能够使用单个分配为 std::share_ptr<MyForm> 所需的内务管理信息和 MyForm 对象分配空间。

std::shared_ptr<>

std::shared_ptr<> 类模板存储一个指向动态分配对象的指针。std::shared_ptr<> 保证当指向它的最后一个 std::shared_ptr<> 被销毁或重置时,它指向的对象将被删除。

std::shared_ptr<> 的实现使用引用计数,并且 std::shared_ptr<> 实例的循环将不会被销毁。如果一个函数持有指向一个对象的 std::shared_ptr<>,而该对象直接或间接持有指向该对象的 std::shared_ptr<>,则对象的引用计数将为 2,并且原始 std::shared_ptr<> 的销毁将使对象以 1 的引用计数悬空。为了避免这些类型的循环引用,您可以使用 std::weak_ptr<> 来向上引用对象层次结构中的对象。

MyForm 类声明如下

class MyForm : public Form
{
    graphics::Factory factory;
    graphics::WriteFactory writeFactory;
    graphics::WriteTextFormat textFormat;
    graphics::ControlRenderTarget renderTarget;
    graphics::SolidColorBrush blackBrush;
    float dpiScaleX;
    float dpiScaleY;
    String text;
public:
    typedef Form Base;

    MyForm();
protected:
    virtual void DoOnShown();
    virtual void DoOnDestroy(Message& message);
    virtual void DoOnDisplayChange(Message& message);
    virtual void DoOnPaint(Message& message);
    virtual void DoOnSize(Message& message);
private:
    void UpdateScale( );
};

MyForm 派生自 Form,一个表示顶级窗口的类,这正是我们示例所需的。graphics::Factory 类是 Direct2D ID2D1Factory 接口的包装器,而 graphics::WriteFactoryDirectWrite IDWriteFactory 接口的包装器。两者都在 MyForm 的构造函数中初始化

MyForm::MyForm()
    : Base(),
      factory(D2D1_FACTORY_TYPE_SINGLE_THREADED),
      writeFactory(DWRITE_FACTORY_TYPE_SHARED),
      dpiScaleX(0),dpiScaleY(0),
      text(L"Windows Development in C++, rendering text with Direct2D & DirectWrite")
{
    SetWindowText(text);
    textFormat = writeFactory.CreateTextFormat(L"Plantagenet Cherokee",72);
    textFormat.SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
    textFormat.SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER);

    UpdateScale( );
}

由于我们的应用程序是单线程的,并且我们完全控制了对象如何交互以及它们处于什么状态,因此我们创建了一个单线程 ID2D1Factory 和一个共享 IDWriteFactory

在构造函数内部,我们使用 writeFactory 创建一个 graphics::WriteTextFormat 对象。graphics::WriteTextFormat 对象描述文本的格式,当需要使用相同的字体大小、样式、粗细、对齐等渲染整个 string 时使用。

我们还希望我们的小应用程序能够在高 DPI 设备上正确渲染,而 UpdateScale 根据桌面的分辨率计算因子,这些因子稍后将用于缩放文本输出的渲染矩形。

void MyForm::UpdateScale( )
{
    factory.GetDesktopDpi(dpiScaleX,dpiScaleY);
    dpiScaleX /= 96.0f;
    dpiScaleY /= 96.0f;
}

此时,我们有一个完全初始化的 MyForm 对象,我们将其传递给 Application 对象的 Run 方法。

auto result = application->Run(form);

现在我们有一个正在运行的 Windows 桌面应用程序,是时候看看 MyForm 类中声明的 5 个 virtual 方法了。这些方法重写了在 Form 类中声明的方法,或者在 Control 类(Form 类的祖先)中声明的方法。

DoOnShown 方法仅在表单第一次显示时调用——任何后续的最小化、最大化、恢复、隐藏、显示或失效和重绘都不会导致此方法再次被调用。因此,这是一个初始化依赖于有效窗口句柄的对象的良好机会。

void MyForm::DoOnShown()
{
    Base::DoOnShown();

    renderTarget = factory.CreateControlRenderTarget(shared_from_this());
    blackBrush = renderTarget.CreateSolidColorBrush(D2D1::ColorF(D2D1::ColorF::Black));
}

renderTarget 是一个 ControlRenderTarget 对象,它是 Direct2D ID2D1HwndRenderTarget 接口的包装器,我们使用此对象在 DoOnPaint 方法上渲染文本

void MyForm::DoOnPaint(Message& message)
{
    Base::DoOnPaint(message);
    ValidateRect();
    RECT rc = GetClientRect();

    renderTarget.BeginDraw();

    renderTarget.SetTransform(D2D1::IdentityMatrix());
    renderTarget.Clear(D2D1::ColorF(D2D1::ColorF::White));
    
    D2D1_RECT_F layoutRect = D2D1::RectF(rc.top * dpiScaleY,rc.left * dpiScaleX,
        (rc.right - rc.left) * dpiScaleX,(rc.bottom - rc.top) * dpiScaleY);
    
    renderTarget.DrawText(text.c_str(),text.length(),textFormat,layoutRect,blackBrush);

    renderTarget.EndDraw();
}

如您所见,我们在发出绘图命令之前调用 renderTarget 对象的 BeginDraw 方法,并在完成绘图后调用 EndDraw 方法,表示绘图已完成。

Direct2D ID2D1HwndRenderTarget 对象是双缓冲的,发出的绘图命令不会立即出现,因为它们是在屏幕外表面上执行的。EndDraw 导致屏幕外缓冲区呈现在屏幕上。

请注意,我们调用 ValidateRect 告诉 Windows 整个客户端区域现在是有效的。

通过调用 renderTarget.SetTransform(D2D1::IdentityMatrix());,我们确保没有发生变换(例如旋转、倾斜或缩放),并且 Clear 绘制了我们美丽的白色背景。接下来,在调用 DrawText 使用构造函数中创建的 textFormatDoOnShown 方法中创建的黑色画笔渲染文本之前,使用之前由 UpdateScale 计算的缩放因子计算 layoutRect

如前所述,renderTarget 使用屏幕外表面,该表面的大小在 DoOnSize 方法中设置

void MyForm::DoOnSize(Message& message)
{
    Base::DoOnSize(message);
    if(renderTarget)
    {
        D2D1_SIZE_U size;

        size.width = LOWORD(message.lParam);
        size.height = HIWORD(message.lParam);
        renderTarget.Resize(size);
    }
}

DoOnDisplayChange 方法

void MyForm::DoOnDisplayChange(Message& message)
{
    UpdateScale( );
    InvalidateRect();
}

允许应用程序处理显示配置的更改。最后,DoOnDestroy 方法用于在窗口关闭时清理渲染目标

void MyForm::DoOnDestroy(Message& message)
{
    Base::DoOnDestroy(message);
    blackBrush.Reset();
    renderTarget.Reset();
}

除了几个 include 语句;我们现在已经完成了应用程序的完整源代码,该应用程序提供了与 DirectWrite Simple Hello World 示例类似的功能,该示例可以在此链接[^]找到。

未知

我确信您注意到没有调用 Release,但这并不意味着程序没有以适当的方式释放接口。

由于我们正在使用基于 DirectX 的 API,因此拥有一个包装 IUnknown 接口指针的类很有用,令人惊讶的是我将这个包装器称为 Unknown

class Unknown
    {
    protected:
        IUnknown* unknown;
    public:
        Unknown();
        explicit Unknown(IUnknown* unknown);
        Unknown(const Unknown& other);
        Unknown(Unknown&& other);
        ~Unknown();
        operator bool() const;
        Unknown& operator = (const Unknown& other);
        Unknown& operator = (Unknown&& other);
        Unknown& Reset(IUnknown* other = nullptr);
    };

它几乎是 COM 对象的智能指针的最小实现,并且用作 harlinn::windows::graphics 命名空间中各种接口包装器的基类,因此值得研究实现细节。

默认构造函数的作用几乎和人们预期的一样,因为它只是将 unknown 设置为 nullptr

Unknown()
    : unknown(nullptr)
{}

然后我们有一个接受 IUnknown 指针的构造函数

explicit Unknown(IUnknown* unknown)
    : unknown(unknown)
{}

它被声明为显式的,因为我不希望编译器自动生成类的实例。请注意,实现不会在接口上调用 AddRef

接下来是复制构造函数,它确实调用 AddRef ——否则整个事情将毫无意义

Unknown(const Unknown& other)
    : unknown(other.unknown)
{
    if(unknown)
    {
        unknown->AddRef();
    }
}

然后,我们有移动构造函数

Unknown(Unknown&& other)
    : unknown(0)
{
    if(other.unknown)
    {
        unknown = other.unknown;
        other.unknown = nullptr;
    }
}

它复制参数管理的指针,并将参数的未知字段设置为 nullptr,从而防止当该对象超出范围时参数调用 Release

~Unknown()
{
    IUnknown* tmp = unknown;
    unknown = nullptr;
    if(tmp)
    {
        tmp->Release();
    }
}

MyForm::DoOnSize 中,您看到了 if(renderTarget) 这个测试,它使用了这个运算符

operator bool() const
{
    return unknown != nullptr;
}

复制赋值运算符看起来像这样

Unknown& operator = (const Unknown& other)
{
    if(unknown != other.unknown)
    {
        if(unknown)
        {
            IUnknown* tmp = unknown;
            unknown = nullptr;
            tmp->Release();
        }
        unknown = other.unknown;
        if(unknown)
        {
            unknown->AddRef();
        }
    }
    return *this;
}

而移动赋值运算符的实现方式如下

Unknown& operator = (Unknown&& other)
{
    if (this != &other)
    {
        IUnknown* tmp = unknown;
        unknown = nullptr;
        if(tmp)
        {
            tmp->Release();
        }
        unknown = other.unknown;
        other.unknown = nullptr;
    }
    return *this;
}

值得注意的是,复制赋值运算符和移动赋值运算符都*防止自赋值*,这会导致过早调用 Release,并且 Reset 方法的实现方式类似

Unknown& Reset(IUnknown* other = nullptr)
{
    if(unknown != other)
    {
        if(unknown)
        {
            IUnknown* tmp = unknown;
            unknown = nullptr;
            tmp->Release();
        }
        unknown = other;
    }
    return *this;
}

另请注意,Reset 方法不会在传入的接口上调用 AddRef

Application

还记得 MyForm::DoOnDestroy 方法吗?

void MyForm::DoOnDestroy(Message& message)
{
    Base::DoOnDestroy(message);
    blackBrush.Reset();
    renderTarget.Reset();
}

也许您想知道为什么我们要调用基类的 DoOnDestroy 方法。Control 类像这样实现 DoOnDestroy 方法

HWIN_EXPORT void Control::DoOnDestroy(Message& message)
{
    OnDestroy(message);
}

其中 OnDestroy 不是另一个方法,而是来自 boost::signals2[^] 库的信号,声明如下

signal<void (Message& message)> OnDestroy;

信号提供了与 .NET 事件在许多方面相似的功能,Application::Run 方法通过将 lambda 表达式连接到 OnDestroy 信号来很好地利用这一点

HWIN_EXPORT int Application::Run
    (std::shared_ptr<Form> mainform, std::shared_ptr<MessageLoop> messageLoop)
{
    if(mainform)
    {
        mainform->OnDestroy.connect( [=](Message& message)
            {
                ::PostQuitMessage(-1);
            });
        mainform->Show();

        int result = messageLoop->Run();
        return result;
    }
    return 0;
}

lambda 表达式调用 PostQuitMessage,导致消息循环在应用程序调用 DoOnDestroy 方法时终止(通常是对 WM_DESTROY 消息的响应),仅适用于参数表单,因此消息循环的生命周期与窗口的生命周期相关联。

结论

您可能已经注意到,本文与其说是关于 Direct2DDirectWrite,不如说是关于简化 Windows C++ 开发。演示应用程序只有一百多行代码,我们不必担心资源泄漏,与原始的 DirectWrite SDK 示例应用程序相比,它应该很容易理解——至少我希望如此。

我对 Unknown 进行了相当详细的阐述,因为似乎对如何实现移动构造函数和移动赋值运算符存在一些误解,我建议任何真正对此主题感兴趣的人阅读 Dave Abrahams 的“右值引用:向前迈进”系列,您可以在这里找到第一篇文章:想要速度?按值传递[^]

历史

  • 2012 年 9 月 30 日 - 初次发布
  • 2012 年 11 月 30 日 - 库更新
  • 2014 年 8 月 20 日 - 多次更新和错误修复
  • 2015 年 1 月 3 日 - 添加了一些新类、一些更新和一些错误修复
© . All rights reserved.