使用 Direct2D & DirectWrite 渲染文本






4.94/5 (39投票s)
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<>
和其他智能指针所实现的编程风格,而不是关于 Direct2D
和 DirectWrite
。该库包含一组包装 Direct2D
和 DirectWrite
功能的类,并添加了一些重要功能
- 错误被转换为异常
- COM 接口生命周期的透明管理
该演示应用程序实现了与 DirectWrite
SDK 示例之一相同的功能,并且代码量显著减少。
现在,我们这些开发显示 3D 内容的应用程序的人习惯于使用 GPU 的强大功能。虽然使用 Direct3D 显示 2D 内容当然是可能的,但我们大多数人不会用它来渲染几行文本,或任何其他可以使用 GDI 或 GDI+ 轻松实现的内容。
从 Windows Vista Service Pack 2 和 Windows 7 开始,我们现在有了一组新的 API,可以促进使用 GPU 进行 2D 渲染,称为 Direct2D
。同时,微软引入了另一个新 API DirectWrite
,支持文本渲染、分辨率无关的轮廓字体以及完整的 Unicode 文本和布局支持。
虽然 Direct2D
和 DirectWrite
的 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::WriteFactory
是 DirectWrite 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
使用构造函数中创建的 textFormat
和 DoOnShown
方法中创建的黑色画笔渲染文本之前,使用之前由 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
消息的响应),仅适用于参数表单,因此消息循环的生命周期与窗口的生命周期相关联。
结论
您可能已经注意到,本文与其说是关于 Direct2D
和 DirectWrite
,不如说是关于简化 Windows C++ 开发。演示应用程序只有一百多行代码,我们不必担心资源泄漏,与原始的 DirectWrite
SDK 示例应用程序相比,它应该很容易理解——至少我希望如此。
我对 Unknown
进行了相当详细的阐述,因为似乎对如何实现移动构造函数和移动赋值运算符存在一些误解,我建议任何真正对此主题感兴趣的人阅读 Dave Abrahams 的“右值引用:向前迈进”系列,您可以在这里找到第一篇文章:想要速度?按值传递[^]
历史
- 2012 年 9 月 30 日 - 初次发布
- 2012 年 11 月 30 日 - 库更新
- 2014 年 8 月 20 日 - 多次更新和错误修复
- 2015 年 1 月 3 日 - 添加了一些新类、一些更新和一些错误修复