诸如 TrueType 之类的矢量字技术主要供我们灵活准确排版之用,但它们也可以充当图形处理的对象。程序员可以访问定义每个文本字符的实际轮廓,并将它们视为矢量图形对象。 这些轮廓可以进行笔划书写、填充、用于剪辑或进行转换。Microsoft® Word 中的常见“艺术字”功能便是以此概念为基础。
认 识到这些字符轮廓的特性和局限性非常重要:它们是完全几何性的,缺少操作系统通常在屏幕上呈现字体时所用的“提示”。通过这些提示可以依据可用的像素网格 智能地对字符进行光栅化处理。因此,这些无提示的字符轮廓在大字号状态或高分辨率设备上看起来效果最佳。它们通常无法满足在屏幕上呈现普通字号文字的要 求。(不过,随着打印机分辨率的提高以及对屏幕图形反失真使用的增多,提示的价值已不像原来那么重要。)
每当遇到新的 Windows® API 时,我都会特意去寻找可访问这些字符轮廓的来源。在 Windows 窗体中,它是 GraphicsPath 类的一部分。AddString 方法的四种重载可让您将字符轮廓添加到一个路径。我所著书籍“Programming Microsoft Windows with C#”(Microsoft Windows 编程——C# 篇)(Microsoft Press, 2002) 和“Programming Microsoft Windows with Microsoft Visual Basic® .NET”(Microsoft Windows 编程——Microsoft Visual Basic® .NET 篇)(Microsoft Press, 2003) 的第 19 章对此过程进行过说明。
在 Windows Presentation Foundation (WPF) 中,提供字符轮廓访问权的类和方法隐藏得更好,但它们确实存在。来自 System.Windows.Media 命名空间的 FormattedText 和 GlyphRun 类均具有名为 BuildGeometry 的方法,这些方法可为特定字体和文本字符串返回 Geometry 对象。在本文中,我将完全使用 FormattedText,因为它是两个类中较容易的一种。我所著书籍“Applications = Code + Markup”(应用程序 = 代码 + 标记)(Microsoft Press, 2006) 的第 28 章和第 30 章中提供了一些 FormattedText 和 BuildGeometry 与二维图形结合使用的示例。
刚开始研究 WPF 中的三维文字时,我很自然地考虑过将这些字符轮廓转为三维文本块的可能性,就像在印刷媒体上或者电视上的飞行徽标效果中看到的那样(请参见图 1 中提供的示例)。我知道这个工作会涉及二维轮廓到三维三角形网格的转换,而除此之外,我只确定一件事:其中涉及的一些编程不会那么简单。
图 1 实心三维文字
FormattedText 和 BuildGeometry
我想大多数 WPF 程序员对 FormattedText 类都没有太多接触。正如文档中所述,此类用于“对绘制文本进行低级别控制”。
FormattedText 构造函数需要一个 TypeFace 对象,该对象定义字体系列、样式(如斜体)、可能的加粗,以及与字体相关的任何拉伸或压缩。此外,FormattedText 构造函数还需要 em 尺寸(字体高度)、用于为字体字符着色的刷子,以及文本字符串本身。
创建 FormattedText 对象后,您可以调用各种方法对文本字符串的子集设置不同的字体、样式或格式。通过属性可以设置行距和文本的其他特征。
FormattedText 对象最常见的用法是与 DrawingContext 类的 DrawText 方法一起使用。通常,您会在覆盖由 UIElement 定义的 OnRender 方法时遇到 DrawingContext 类。这是应用程序能做的最低级别的图形输出,仍视为纯 WPF 应用程序。DrawText 方法只需要一个 FormattedText 对象和文本开始的坐标点。
相对于 FormattedText 中的任何其他内容,BuildGeometry 方法似乎有些格格不入。该方法具有一个参数(类型为 Point 的原点),并会返回一个 Geometry 对象。
Geometry 是 WPF 中的重要类,它显然与传统图形路径有关。Geometry 对象是指定为坐标点的直线与曲线的集合。其中的某些直线和曲线可能会有连接;某些连接的直线和曲线可能会闭合,用以描述封闭的区域。Geometry 对象中未包含呈现概念。在二维图形编程中,要呈现一个 Geometry 对象,您只需将它传递给便于使用的 Path 类即可,该类是高级 Shapes 库的一部分。另一种方法是 GeometryDrawing,它以 Geometry 对象、Brush 和 Pen 为基础。
进行二维图形编程时,通常几乎不需要深入特定的 Geometry 对象。但是将 Geometry 对象转换为三维文字的工作则大不相同。那么,FormattedText.BuildGeometry 返回的 Geometry 对象究竟是什么呢?
Geometry 是派生了其他七个类的抽象类。我的经验如下:从 FormattedText.BuildGeometry 返回的 Geometry 对象实际上是一个或多个嵌套 GeometryGroup 对象,其中包含多个 PathGeometry 对象,每个对象对应文本字符串中的一个字符。每个 PathGeometry 对象均包含一个或多个可定义封闭路径的 PathFigure 对象。有些字符(如 l、t 或 x)只需要一个 PathFigure。其他字符则需要两个。如小写的 i,其中的点便需要第二个 PathFigure。O 的外圈轮廓和内圈轮廓各自都需要一个 PathFigure。大写的 B 需要三个 PathFigure 对象。百分比符号则需要五个。
每 个 PathFigure 都是 PathSegment(另一个抽象类)类型的对象的集合。根据我的经验,与文本轮廓相关的 PathFigure 对象包含多个 LineSegment、PolyLineSegment、BezierSegment 和 PolyBezierSegment 类型的对象,这些对象构成了单一封闭路径的定义。
WPF 三维没有折线或 Bezier 曲线的概念,但三角形网格以点的集合为基础,因此第一步需要将 FormattedText.BuildGeometry 的 Geometry 对象转换为一系列的封闭折线。原以为这会很简单。但我很快发现,将这些折线转换为三角形网格的算法显然是一项复杂的数学工作,非常可能超出我的能力范围。
轮廓和网格
在本杂志 2007 年 4 月期中,我讨论了生成与 WPF 三维图形配合使用的 MeshGeometry3D 对象的机制(请参阅
msdn.microsoft.com/msdnmag/issues/07/04/Foundations)。 最起码,您需要设置 MeshGeometry3D 对象的 Positions 和 TriangleIndices 属性。Positions 属性是三维空间中各点的集合。TriangleIndices 集合说明了如何根据这些点构造三角形。TriangleIndices 中的每三个整数都会引用 Positions 集合中的三个点来形成一个三角形。
我发现,从文本轮廓生成的折线会形成 MeshGeometry3D 的 Positions 集合的一部分。图 2 显示了 sans-serif 字体的大写字母 A,由两条封闭的折线及十一个点组成。第二个图显示了该字母如何划分为七个三角形。对于类似的简单字符,手工书写轻而易举,但是描述代码中的过程对我来说完全没有那么清晰。
图 2 由点和三角形定义的字母
显 而易见,您需要确保每个三角形的边都不会偏离出字符的边界,并且需要确保字符的整个外观由三角形集合所决定。同时请记住,我此处所用的示例是简单的 sans-serif 字体字符。考虑一下 serifed 字体的大写字母 S 的话,就会发现这个工作变得异常困难。现在有一种称为 Delaunay Triangulation 的技术可能会有用,但其中的数学相当复杂。
庆幸的是,希望还是有的。在关注此问题时,我看到一个广告中的某些文字采用了不同的三维文字方式,只包含轮廓,而将每个字符的主体留空。我意识到,这就是我可以做的事。首先我需要一个类名,于是我想到了 RibbonText。
Text3D 层次结构
本文的可下载代码中包含一个名为 Text3D 的 Visual Studio® 解决方案。该解决方案由以下部分组成:一个名为 Petzold.Text3D 的 DLL 项目和使用该 DLL 的若干个基于 XAML 的小型演示程序。请注意,DLL 中的文件具有命名空间 Petzold.Text3D。
Petzold.Text3D.dll 库中的类层次结构以 ModelVisualBase 开头,这是从 WPF 三维类 ModelVisual3D 派生的抽象类,即我在本杂志 2007 年 4 月期中讨论过的技术。但是,此 ModelVisualBase 类与前一专栏中的 ModelVisualBase 不尽相同,因为它需要多一些灵活性。但请注意,其中的概念是相同的:ModelVisualBase 会在内部存储 GeometryModel3D 对象和 MeshGeometry3D 对象,并定义要传输到 GeometryModel3D 对象的公共 Material 和 BackMaterial 属性。
Petzold.Text3D 类中的大多数属性受依赖关系属性支持。ModelVisualBase 类定义了两个 PropertyChanged 事件处理程序(一个静态,一个实例),后代类在定义依赖关系属性时便可使用这两个处理程序。实例版本会适当准备内部 MeshGeometry3D 对象的各种属性以应对各种变化(我在 4 月份专栏中讨论过此过程),然后调用抽象方法 Triangulate。后代类可以覆盖 Triangulate 以定义 MeshGeometry3D 的各种集合。
在 Petzold.Text3D 库中,抽象类 Text3DBase 从 ModelVisualBase 派生而来。此类定义了大量与文本相关的属性,包括 Text、FontFamily、FontStyle、FontWeight、FontStretch 和 FontSize,全部都是 FormattedText 构造函数需要的属性。该类还定义了 BuildGeometry 方法所需的 Origin 属性。上述任一属性发生变化时,该类都会创建一个新的 FormattedText 对象,并根据 BuildGeometry 返回的 Geometry 对象设置 Text3DBase 的第八个属性 TextGeometry:
FormattedText formtxt =
new FormattedText(Text, CultureInfo.CurrentCulture,
FlowDirection.LeftToRight,
new Typeface(FontFamily, FontStyle,
FontWeight, FontStretch),
FontSize, Brushes.Transparent);
TextGeometry = formtxt.BuildGeometry(Origin);
此 TextGeometry 对象可用于所有的后代类。
进 行堆阵分配之后,Text3DBase 类才能创建新的 TextGeometry 对象。该类需要分配新的 FormattedText 对象,而 BuildGeometry 无疑进行了很多自身内存分配。这些堆阵分配意味着,由 Text3DBase 类定义的属性可能不应为动态。我已经使用由 UIPropertyMetadata 定义的 IsAnimationProhibited 属性来标记一些将会是动画首选的属性。
我 所编写的创建三维文字的所有类中,Text3DBase 定义的 Origin 属性是唯一表明文字在三维空间中所处位置的属性,该属性的类型为 Point,表明了二维空间中的位置。我考虑过定义更大的一组属性,将文字精确地放置在三维空间中。这些属性不仅必须包含三维文字的原点,还必须包含表明 基线方向的三维矢量,以及表明垂直方向的另一三维矢量。我决定选用 Origin 属性来将文字放置在三维空间中的 XY 平面上,然后使用转换来执行其他所有定位操作。此方法最大的好处就是简化了数学计算。
由 Text3DBase 定义的 FontSize 属性指出了字体的 em 尺寸,这与字体字符的总高度大致相关。在三维中,字体还有深度。DeepTextBase 类从 Text3DBase 派生而来,纯粹用于定义 Depth 属性。(您最后会看到此扩展类层次结构的原因;在这些类的编程过程中发生了相当多的重构,从而形成了现在的结构。) 由于文字位于 XY 平面上,因此我考虑用 Depth 属性来说明文字在负 Z 轴上扩展的深度。
到目前为止,这些类还未进行任何实质性工作。图 3 所示的抽象类 GeometryTextBase 从 DeepTextBase 派生而来,并且覆盖由 ModelVisualBase 定义的 Triangulate 方法。借助 Geometry 类中的 GetFlattenedPathGeometry 方法,GeometryTextBase 类可将 Geometry 对象(作为 TextGeometry 属性出现)转换为多条相连的折线,这些折线与组成文本轮廓的封闭图形相对应。针对每个封闭图形,GeometryTextBase 类会调用一个抽象方法,如下所示:

Figure 3 GeometryTExtBase 类
public abstract class GeometryTextBase : DeepTextBase
{
// Field prevent re-allocations during mesh generation.
CircularList<Point> list = new CircularList<Point>();
protected override void Triangulate(
DependencyPropertyChangedEventArgs args,
Point3DCollection vertices, Vector3DCollection normals,
Int32Collection indices, PointCollection textures)
{
// Clear all four collections.
vertices.Clear();
normals.Clear();
indices.Clear();
textures.Clear();
// Convert TextGeometry to series of closed polylines.
PathGeometry path =
TextGeometry.GetFlattenedPathGeometry(0.001,
ToleranceType.Relative);
foreach (PathFigure fig in path.Figures)
{
list.Clear();
list.Add(fig.StartPoint);
foreach (PathSegment seg in fig.Segments)
{
if (seg is LineSegment)
{
LineSegment lineseg = seg as LineSegment;
list.Add(lineseg.Point);
}
else if (seg is PolyLineSegment)
{
PolyLineSegment polyline = seg as PolyLineSegment;
for (int i = 0; i < polyline.Points.Count; i++)
list.Add(polyline.Points[i]);
}
}
// Figure is complete. Post-processing follows.
if (list.Count > 0)
{
// Remove last point if it's the same as the first.
if (list[0] == list[list.Count - 1])
list.RemoveAt(list.Count - 1);
// Convert points to Y increasing up.
for (int i = 0; i < list.Count; i++)
{
Point pt = list[i];
pt.Y = 2 * Origin.Y - pt.Y;
list[i] = pt;
}
// For each figure, process the points.
ProcessFigure(list, vertices, normals,
indices, textures);
}
}
}
// Abstract method to convert figure to mesh geometry.
protected abstract void ProcessFigure(CircularList<Point> list,
Point3DCollection vertices, Vector3DCollection normals,
Int32Collection indices, PointCollection textures);
}
protected abstract void ProcessFigure(
CircularList<Point> list, Point3DCollection vertices,
Vector3DCollection normals, Int32Collection indices,
PointCollection textures);
第 一个参数是图形中二维点的集合。CircularList 是我定义的集合类,功能类似于一个循环缓冲区。每当按索引访问成员时,该对象会规范化索引,将其调整到适当范围。换句话说,索引 -1 会访问集合的最后一个成员,索引 list.Count 则会访问集合的第一个成员。该集合包含封闭折线中的所有点。
ProcessFigure 的其他参数分别对应 MeshGeometry3D 对象的 Positions、Normals、TriangleIndices 和 TextureCoordinates 集合。实现 ProcessFigure 方法的类至少需要依据 CircularList 集合中的点来填充顶点和索引集合。
RibbonText 和 SliverText
我编写的真正生成三维文字的第一个类是 RibbonText,如图 4 所示。正如您看到的,该类从 GeometryTextBase 派生而来,完全由 ProcessFigure 方法的实现构成。它生成的 Point3D 对象完全基于 CircularList 集合中的二维点和 Depth 属性。这些点在 XY 平面(其中 Z 等于零)上的点以及 XY 平面后的 Depth 单元之间交替。索引集合将这两组点连接起来,以定义一系列的三角形。

Figure 4 RibbonText 类
public class RibbonText : GeometryTextBase
{
protected override void ProcessFigure(CircularList<Point> list,
Point3DCollection vertices, Vector3DCollection normals,
Int32Collection indices, PointCollection textures)
{
int offset = vertices.Count;
for (int i = 0; i <= list.Count; i++)
{
Point pt = list[i];
// Set vertices.
vertices.Add(new Point3D(pt.X, pt.Y, 0));
vertices.Add(new Point3D(pt.X, pt.Y, -Depth));
// Set texture coordinates.
textures.Add(new Point((double)i / list.Count, 0));
textures.Add(new Point((double)i / list.Count, 1));
// Set triangle indices.
if (i < list.Count)
{
indices.Add(offset + i * 2 + 0);
indices.Add(offset + i * 2 + 2);
indices.Add(offset + i * 2 + 1);
indices.Add(offset + i * 2 + 1);
indices.Add(offset + i * 2 + 2);
indices.Add(offset + i * 2 + 3);
}
}
}
}
请 记住,会为特定的文本字符串多次调用 ProcessFigure 方法。GeometryTextBase 中的 Triangulate 方法负责最初清除 MeshGeometry3D 集合;ProcessFigure 类使用名为偏移的整数来确定添加到顶点集合的新点的索引。
图 5 显示了使用 RibbonText 类的一个小 XAML 文件,图 6 则显示了其外观。从某种意义上说,它看起来要比普通的三维文本块“更美观”一些,可能因为它确实不寻常。但是从算法上讲,这是能够想到的三维文字的最简单形式。

Figure 5 RibbonTextDemo.xaml
<!-- RibbonTextDemo.xaml by Charles Petzold, June 2007 -->
<Page xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:src="clr-namespace:Petzold.Text3D;assembly=Petzold.Text3D"
WindowTitle="RibbonText Demo"
Title="RibbonText Demo">
<Viewport3D>
<src:RibbonText Text="Ribbon"
FontFamily="Times New Roman" Depth="2">
<src:RibbonText.Material>
<DiffuseMaterial Brush="Cyan" />
</src:RibbonText.Material>
<src:RibbonText.BackMaterial>
<DiffuseMaterial Brush="Pink" />
</src:RibbonText.BackMaterial>
</src:RibbonText>
<!-- Lights. -->
<ModelVisual3D>
<ModelVisual3D.Content>
<Model3DGroup>
<AmbientLight Color="#404040" />
<DirectionalLight Color="#C0C0C0"
Direction="2 -3 -1" />
</Model3DGroup>
</ModelVisual3D.Content>
</ModelVisual3D>
<!-- Camera. -->
<Viewport3D.Camera>
<PerspectiveCamera Position="-3 0 8" UpDirection="0 1 0"
LookDirection="1 0 -2" FieldOfView="45" />
</Viewport3D.Camera>
</Viewport3D>
</Page>
图 6 RibbonTextDemo 外观
这 些字符实际上是空心的。如果从正面看文字,那么您可以直接看透它。因为只有从某一角度看文字,您才会看到外部和内部的颜色是不同的。字符的外部使用 Material 画笔(在 RibbonTextDemo.xaml 中设置为 Cyan)着色,内部则使用 BackMaterial 画笔(设置为 Pink)着色。但是对于某些字体,这些颜色可以反转。这取决于定义字符轮廓的各个点的方向。可以想象,对于相同字体中的不同字符,此方向甚至会有所不 同。
RibbonText 类定义了 TextureCoordinates 集合的点,因此您并不局限于实心颜色画笔。TextureCoordinates 集合中的点以 Y 坐标 0 表示功能区的前景部分,1 表示背景部分,X 坐标则以 CircularList 集合中各点的索引为基础。对此文本所用的任何非实心画笔都会分别应用于每个功能区。当您以起始坐标 (0, 0) 和结尾坐标 (0, 1) 使用 LinearGradientBrush 时,便会产生最可预测的结果。其他画笔可能会在文本字符中无法预测的位置暴露出可见接缝。
比 RibbonText 更难一些的是被我称为 SliverText 的类。此类也仅仅侧重于文本字符的轮廓,但它赋予这些轮廓一个非零宽度,我将其定义为 SliverWidth 属性。图 7 显示了使用默认 FontSize 1、默认 Depth 1 和默认 SliverWidth 0.05 运行的 SliverTextDemo 程序。我根据各点的 X 和 Y 坐标定义了 SliverText 的 TextureCoordinates,允许画笔因文本正面不同而有所不同。该示例显示了一个渐变画笔。字符的顶部看上去有点圆,因为顶部三角形和侧面三 角形在边缘共享坐标。WPF 三维会根据平均值计算法线(它控制光线反射)。
图 7 SliverTextDemo 外观
如果您将 SliverWidth 设置为比 FontSize 高很多的值,那么字符会相互合并。如果之后文字通过转换成为动态,那么可能会有一些难看的颤动,因为表面会争夺对前景的控制。(此效果的技术术语称为“Z 值争夺”。)
SliverText 类某些二维分析几何中纳入一些偏移。每个字符轮廓都是一条二维折线,但 SliverText 需要两组平行折线。我曾试着使用由 Geometry 定义的 GetWidenedPathGeometry 方法,但我在加宽路径方面的经验是,它们经常会带来我极力想避免的产物。
因 此,我改为自己加宽路径。此工作需要计算与折线中每条单独的线平行的线。然而,这些平行线通常需要缩短或加长,以便达到与基础路径相同的连续性和形状。助 我一臂之力的是我编写的一个小结构,称为 Line2D。此结构将 Line2D 对象和矢量的新增项定义为线条位移。两个 Line2D 对象相乘会返回一个表明交集的 Point 对象。
SolidText 突破
在我开始编写 RibbonText 和 SliverText 的多个月后,我很担心在三维文字道路上不会有更大进展。我偶尔会回到将文本字符外观分成三角形的问题上,但是我一直找不到一种方法,能够不牵涉相当可怕的数学。
当 然,我非常了解如何将文本字符串放在三维的平面中。您唯一需要的是基于 TextBlock 组件的 VisualBrush。我当然想到过可以将该平面覆盖在 RibbonText 对象顶部,并创建实心文字效果。但据我的经验,这些问题涉及混合光栅化文字(TextBlock 所显示的内容)和根据几何轮廓构造的文字。这两种不同的算法在视觉上并不匹配。
当我意识到可以依据 RibbonText 中使用的相同几何学,改用 DrawingBrush 覆盖三维空间的平面时,我终于找到了突破口。概念很清晰;但实现过程并非如此。
我 将生成此 DrawingBrush 的类称为 PlanarText,它从 Text3DBase 派生而来。PlanarText 需要 Text3DBase 生成的 TextGeometry 属性,但不需要由 DeepTextBase 定义的 Depth 属性,因为(正如类名所示)PlanarText 是在平面上显示文字。但是,PlanarText 类确实定义了名为 Z 的属性,表明文本平面所在位置与 XY 平面平行。
PlanarText 根据两个三角形简单定义一个平面矩形,从而实现了 Triangulate 方法。此矩形四个角的坐标来自与 Geometry 对象(从 TextGeometry 属性获得)相关的 Bounds 属性。
PlanarText 与其他从 ModelVisualBase 派生的类的明显区别在于对 Material 和 BackMaterial 属性的处理方式不同。
我 发现有一点非常有趣,最初设想 ModelVisualBase 时,这些 Material 和 BackMaterial 属性都被认为是个麻烦。ModelVisualBase 从 ModelVisual3D 派生而来,但 ModelVisual3D 并不定义 Material 和 BackMaterial 属性。定义 Material 和 BackMaterial 属性的 WPF 三维类实际上是 GeometryModel3D,ModelVisualBase 将该类的一个实例内部存储为其 Content 属性。
ModelVisualBase 需要定义 Material 和 BackMaterial 属性,这样您便可以在标记中的派生类中进行设置,如下所示:
<src:RibbonText Text="Ribbon">
<src:RibbonText.Material>
<DiffuseMaterial Brush="Cyan" />
</src:RibbonText.Material>
<src:RibbonText.BackMaterial>
<DiffuseMaterial Brush="Pink" />
</src:RibbonText.BackMaterial>
</src:RibbonText>
与 ModelVisualBase 所定义的 Material 和 BackMaterial 属性相关的是属性更改处理程序 MaterialPropertyChanged,它只是将分配到这些属性的所有对象传输给内部 GeometryModel3D 对象的相同属性。
但 是 PlanarText 需要做一些不同的工作。例如,当 PlanarText 的 Material 属性设置为 DiffuseMaterial 对象时,PlanarText 需要提取与该 DiffuseMaterial 相关的 Brush 对象,然后根据此现有的画笔和 TextGeometry 属性创建新的 DrawingBrush 对象:
new DrawingBrush(new GeometryDrawing(brush, null, TextGeometry))
此 DrawingBrush 会成为新 DiffuseMaterial 对象的基础,PlanarText 然后会将该对象设置为内部 GeometryModel3D 的 Material 属性。
如 果分配到 PlanarText 的 Material 属性是 DiffuseMaterial 对象,那么这一过程听上去相当简单。但是,分配到 Material 属性可能是 MaterialGroup 对象,并且此 MaterialGroup 对象可能具有 DiffuseMaterial、SpecularMaterial、EmissiveMaterial 甚至是其他嵌套 MaterialGroup 类型的子项。通常情况下,PlanarText 类需要构造一个要分配给其内部 GeometryModel3D 对象的 Material 对象平行结构,并且与这些 Material 对象相关的每个 DrawingBrush 都需要通过 Brush 对象(来自与 PlanarText 相关的 Material 对象)计算出来。
仅仅因为此 Material 传输逻辑的原因,PlanarText.cs 成了 Text3D 库中最长的文件,即使结果看起来并不多,如图 8 所示。
图 8 PlanarTextDemo 外观
我 仍对 PlanarText 类不太满意。例如,如果文字大小发生变化,PlanarText 就需要重新创建画笔。如果该类的公共 Material 和 BackMaterial 属性的结构与内部 GeometryModel3D 对象的属性结构相同,则可避免重新创建大量的 Material 对象。但是如果将与这些 Material 对象相关的其中一个画笔动态化(可能通过 ColorAnimation),则 PlanarText 必须在每次传递时都重新创建 GeometryDrawing 和 DrawingBrush 对象,那样成本会很高。
PlanarText 本身不是很重要,但在实现 SolidText 类过程中起了相当作用。图 9 所示的 SolidText 从 DeepTextBase 派生而来,这意味着它具备 Text3DBase 定义的所有文本相关属性及 Depth 属性。但 SolidText 本身无意于生成任何三角形网格。它会覆盖 Triangulate 方法,但只从该方法返回。

Figure 9 SolidText 类
public class SolidText : DeepTextBase
{
public SolidText()
{
// Create RibbonText and two PlanarText children.
RibbonText ribbon = new RibbonText();
ribbon.Depth = Depth;
Children.Add(ribbon);
PlanarText planar = new PlanarText();
Children.Add(planar);
planar = new PlanarText();
planar.Z = -Depth;
Children.Add(planar);
}
// SideMaterial dependency property and property.
public static readonly DependencyProperty SideMaterialProperty =
DependencyProperty.Register("SideMaterial",
typeof(Material), typeof(SolidText),
new PropertyMetadata(SideMaterialChanged));
public Material SideMaterial
{
set { SetValue(SideMaterialProperty, value); }
get { return (Material)GetValue(SideMaterialProperty); }
}
// SideMaterialChanged handlers.
static void SideMaterialChanged(DependencyObject obj,
DependencyPropertyChangedEventArgs args)
{
((SolidText)obj).SideMaterialChanged(args);
}
void SideMaterialChanged(DependencyPropertyChangedEventArgs args)
{
// Transfer SideMaterial to RibbonText.
Text3DBase txtbase = Children[0] as Text3DBase;
txtbase.Material = args.NewValue as Material;
txtbase.BackMaterial = args.NewValue as Material;
}
// MaterialChanged override.
protected override void MaterialPropertyChanged(
DependencyPropertyChangedEventArgs args)
{
// Transfer Material and BackMaterial properties to PlanarText.
if (args.Property == MaterialProperty)
((PlanarText )Children[1] ).Material =
args.NewValue as Material;
else if (args.Property == BackMaterialProperty)
((PlanarText)Children[2]).BackMaterial =
args.NewValue as Material;
}
// TextPropertyChanged override.
protected override void TextPropertyChanged(
DependencyPropertyChangedEventArgs args)
{
base.TextPropertyChanged(args);
// Transfer text-related property to all three children.
for (int i = 0; i < 3; i++)
{
Text3DBase txtbase = Children[i] as Text3DBase;
txtbase.SetValue(args.Property, args.NewValue);
}
}
// PropertyChanged override.
protected override void PropertyChanged(
DependencyPropertyChangedEventArgs args)
{
if (args.Property == DepthProperty)
{
// Set Depth property to RibbonText and PlanarText children.
double depth = (double)args.NewValue;
((DeepTextBase )Children[0]).Depth = depth;
((PlanarText)Children[2]).Z = -depth;
}
base.PropertyChanged(args);
}
// Move on, move on. Nothing to see here.
protected override void Triangulate(
DependencyPropertyChangedEventArgs args,
Point3DCollection vertices, Vector3DCollection normals,
Int32Collection indices, PointCollection textures)
{}
}
实 际上,SolidText 会充分利用它从 ModelVisual3D 继承的属性,即 Children 属性。SolidText 可具有类型 ModelVisual3D 的子项,因此也可具有 RibbonText 和 PlanarText 类型的子项。应用到 SolidText 的任何转换都会应用到它所有的子项。此外,SolidText 定义了要应用到 RibbonText 子项的 SideMaterial 属性。SolidText 的主要工作是将对其自身设置的属性分发到其所有子项。
SolidTextDemo 程序会旋转 SolidText 对象,这可在上文的图 1 中看到。为测试 PlanarText 中的 Material 传输逻辑,我为该图提供了一个渐变画笔和旋转时可捕获光线的反射材料。
改进工作
解 决了将 RibbonText 和 PlanarText 图形组合到统一的文本块这一问题后,我决定尝试将表面变得圆滑些。EllipticalText 类与 SliverText 相似,只是文本字符轮廓上的每个点变成了椭圆而不是矩形。EllipticalText 类实际上要比 SliverText 短一点。
RoundedText 从 SolidText 派生而来,并使用其构造函数来替换 SolidText 通过 EllipticalText 对象创建的 RibbonText 子项。此组合使得文字在 PlanarText 对象的边缘具备了有些圆形的外观,并让文字块的中间部分饱满一点,在图 10 中可以清楚地看到。
图 10 实心三维文字
圆形程度由 EllipticalText 和 RoundedText 的 EllipseWidth 属性控制,默认值为 0.05。如果作为 TextSize 属性一部分的该属性设置得过大,将会导致字符相互合并,并在动画期间出现 Z 值争夺。
我希望有一天能够实现一种算法,让我们更灵活地将矢量字分成三角形。就现在而言,这些类已经足够。我知道目前世界上正严重缺乏融入了三维文字效果的飞行徽标,我谨希望此处的微薄贡献能一解燃眉之急。
代码下载 (171 KB)