WPF 引入了一个扩展的 3D 模型,允许您使用简单标记构建复杂的 3D 场景。辅助类提供了命中测试、基于鼠标的旋转以及其他基本构件。并且几乎所有的计算机都可以显示3D内容这一点要归功于当缺少显卡支持时 WPE退而使用软件涫染的能力。
3D绘图基础
WPF中的3D绘图涉及以下4可要点:
- 视口,用来驻留3D内容
- 3D对象
- 照亮部分或整个3D场景的光源
- 摄像机,提供在3D场景中进行观察的视点
当然,更复杂的3D场景将包含多个对象并且可能包含多个光源(如果3D对象本身发光的话,也可创建不需要光源的3D对象)。然而,这些基本要素提供了一个良好开端。
那么使用WPF3D支持的优点时什么呢?
- 可创建一些效果,而如果使用模拟的 3D 模型创建这些效果,就需要非常复杂的计算。一个好的例子是光照效果,如反射,当使用多个光源和具有不同反射属性的不同材质时,反射会变得非常复杂。
- 作为一组 3D 对象绘制的内容进行交互,这极大地扩展了通过代码能够完成的工作。例如,一旦构建期望的 3D 场景,旋转对象或者绕着对象旋转摄像机就变得很容易了。而如果使用 2D 编程完成相同的工作,就需要大量的代码(和数学知识)。
视口
如果希望使用 3D内容,需要有容器来包含 3D内容。这个容器是 Viewport3D类,该类位于 System.Windows.Controls
名称空间。Viewport3D类继承自FrameworkElement类,所以它可以放到能够放置正常元素的任何地方。例如,可以使用它作为窗口或页面的内容,也可以将它放到更复杂的布局中。
Viewport3D 类只应用于复杂的 3D编程。它只增加了两个属性——Camera
和 Children
,Camera属性定义了 3D 场景的观察者,Children 属性包含了希望放在场景中的所有 3D 对象。有趣的是,照亮 3D 场景的光源本身也是视口中的一个对象。
在 Viewport3D 类的继承属性中,有一个属性特别重要:ClipToBounds。如果将该属性设置为 true(默认值),超出视口边界的内容将被剪裁掉。如果设置为 false,内容会显示在相邻元素的上面。这种行为和 Canvas 控件的 ClipToBounds 属性的行为相同。然而,当使用 Viewport3D类时有如下重要的区别:性能。如果将 Viewport3D.ClipToBounds 属性设置为 false,当渲染复杂的、频繁更新的 3D场景时,可显著提高性能。
3D对象
视口能够驻留所有继承自Visual3D类(该类位于System.Windows.Media.Media3D名称空间在该名称空间中包含了绝大多数的 3D 类)的 3D 对象。然而,创建 3D 可视化对象需要做的工作可能比您预料的要多。在1.0版本中,WPF库没有提供 3D形状图元的集合。如果希望创建立方体、圆柱或环形曲面等,需要自己动手。
WPF 团队做出的最佳设计决策之一是,使用和 2D 绘图类相同的方式构造 3D绘图类。这意味着很快就能理解大量核心 3D 类的目的(即使不知道如何使用它们)。下表描述了它们之间的关系。
2D类 | 3D类 | 注释 |
---|---|---|
Visual | Visual3D | Visual3D 类是所有 3D对象(在 Viewport3D 容器中渲染的对象)的基类,与 Visual类类似,可以使用 Visual3D类派生出轻量级的3D形状或者创建更加复杂的、提供了丰富的事件集和框架服务的3D控件。然而,不会得到很多的帮助。您可能更愿意使用Visual3D的某个派生类,如ModelVisual3D或ModelUI Element3D |
Geometry | Geometry3D | Geometry 类是一种定义 2D 图形的抽象方式。通常,几何图形用于定义由弧线、直线以及折线构成的复杂图形。Geometry3D类是3D中的类似角色–表示 3D 表面。然而,虽然有几个 2D 几何图形,但 WPF 只提供了一个继承自Geometry3D类的具体类:MeshGeometry3DMeshGeometry3D类在3D绘图中非常重要,因为将使用该类定义所有3D 对象 |
GeometryDrawing | GeometryModel3D | 有几种方法可以使用2DGcometry对象。可将它们封装到GeometyDrawing对象中,并用于绘制元素的表面或 Visual对象的内容。GeometryModel3D 类用于相同的目的——使用Geometry3D对象填充Visual3D 对象 |
Transform | Transform3D | 有几种方法可以使用2DGcometry对象。可将它们封装到GeometyDrawing对象中,并用于绘制元素的表面或 Visual对象的内容。GeometryModel3D 类用于相同的目的–使用Geometry3D对象填充Visual3D 对象 |
首先,您可能会发现弄清楚这些类之间的关系有些困难。本质上,Viewport3D 包含 Visual3D对象。为了给 Visual3D对象添加一些内容,需要定义 Geometry3D 对象来描述形状,并将其封装到 GeometryModel3D 对象中。然后可以使用 GeometryModel3D 对象作为 Visual3D 的内容。下图显示了它们之间的这种关系。
这个具有两个步骤的过程–定义希望使用的抽象形状,然后使用可视化对象将形状融合在一起–对于 2D 绘图是一种可选的方法。然而,对于 3D 绘图却是必需的,因为在库中没有预先构建好的 3D类(WPF 团队成员以及其他成员已经在线发布了一些示例代码,以弥补这一问题,但仍处于演化阶段)。
这个具有两个步骤的过程之所以很重要,还因为 3D 模型比 2D 模型更复杂一些。例如,当创建 Geometry3D 对象时,不仅需要指定形状的顶点,还需要指定构成它们的材质。不同的材质有不同的属性,用于反射和吸收灯光。
几何图形
首先需要构建几何图形。在前面已经学习过,只有一个类用于该目的:为构建 3D 对象,MeshGeometry3D
类
很自然,MeshGeometry3D对象表示网格。如果您以前曾经处理过3D绘图(或阅读过一些有关现代显卡底层技术的内容),可能已经知道**计算机更喜欢通过三角形构建 3D图画。因为三角形是定义表面最简单、最基本的方法。三角形之所以简单,是因为定义每个三角形只需要三个点(位于拐角的顶点)。弧面和曲面明显更加复杂。三角形是最基本的元素,因为边缘是直线的其他形状(如正方形、矩形以及更复杂的多边形)可被分割成三角形集合。**这可能更好也可能更坏,现代图形硬件和图形程序就构建在这个核心抽象的基础上。
显然,希望使用的大多数3D对象看起来不像简单的平面三角形。而是需要组合三角形–有时只需要几个,但通常需要数百个或数千个三角形,它们彼此以不同的角度对齐。网格就是三角形的这种组合。通过足够多的三角形,最终可创建出任何内容,包括复杂的表面(当然,这个过程中会涉及性能,并且3D表面常将某些类型的位图或2D内容映射到网格中的三角形上,以通过更小的开销构造复杂表面的感觉。WPF支持这种技术)。
名称 | 说明 |
---|---|
Positions | 包含定义网格的所有点的集合。每个点是三角形中的一个顶点。例如,如果网格有10个完全独立的三角形,在集合中将有30个点。更常见的情形是,一些三角形共享它们的边,这意味着一个点会成为几个三角形的顶点。例如,立方体需要12个三角形(每个侧面两个三角形),但只需要8个不同的点。可以选择多次定义同一个共享的点,从而可以更好地控制如何分别使用 Normals属性进行着色的三角形,这可能会使问题更加复杂 |
TriangleIndices | 定义三角形。这个集合中的每个条目通过引用Positions集合中的三个点来表示三角形 |
Normals | 为每个顶点(Positions 集合中的每个点)提供一个向量。这个向量指定了顶点在进行光照计算时使用的角度。当WPF为三角形表面着色时,使用法线向量为三角形的每个顶点度量光照。然后为了填充三角形表面,在这三个点之间进行插值。获取合适的法线向量,使得三维对象着色有很大的区别–例如,可使三角形之间的分割边混合在一起或者显示为一条清晰的线 |
TextureCoordinates | 当使用 VisualBrush 对象绘制 3D对象时,该属性定义了如何将一幅 2D 纹理映射到 3D 对象。TextureCoordinates集合为Positions集合中的每个3D点提供了一个2D点 |
下面的示例显示了一个最简单的网格,它只包含一个三角形。使用的单位并不重要,因为可移动摄像机使其更近或更远,并且可使用变换改变单个 3D对象的尺寸或位置。重要的是坐标系统,下图显示了该坐标系统。正如您可能看到的,X轴和Y轴与2D绘图中的X轴和Y轴的方向相同。2轴是新加的。当2轴的值减小时,点向远处移动。当乙轴的值增加时,点向近处移动。
下面的 MeshGeometry元素可用于定义 3D 可视化对象中的形状。这个示例中的 Mesh-Geometry3D 对象没有使用 Normals 属性以及 TextureCoordinates 属性,因为这个形状很简单,并将使用 SolidColorBrush画刷进行绘制:
1 | <MeshGeometry3D Positions="0,0,0 1,0,0 0,1,0" TriangleIndices="0 1 2" /> |
在此,显然只有三个点,这三个点按顺序放置在 Positions 属性中。在 Positions 属性中放置这三个点的顺序并不重要,因为 Trianglelndices 属性清晰地定义了三角形。本质上,TriangleIndices属性表明有一个三角形,该三角形由点#0、#2 和#1构成。换句话说,Trianglelndices 属性告诉 WPF通过绘制从点(-1.0,0)到点(1,0,0),再到点(0,1,0)之间的直线来绘制三角形。
几何图形模型和表面
一旦正确配置期望的MeshGeometry3D
对象,就需要将之封装进GeometryModel3D
对象中中。
GeometryModel3D 类只有三个属性:Geometry
、Material
以及BackMaterial
。Geometry 属性使用 MeshGeometry3D对象定义 3D对象的形状。此外,可使用 Material和 BackMaterial 属性定义如何构成形状的表面。
表面是很重要的,这有两个原因。首先,表面定义了对象的颜色(尽管可使用更复杂的画刷绘制纹理而不是使用单纯的颜色)。其次,表面定义了材质如何响应灯光。
WPF提供了4个材质类,这些类继承自抽象的 Material类(该类位于 System.Windows.Media.Media3D 名称空间)。下表列出了这4个类。这个示例将使用 DifuseMaterial 类,这是最常用的选择,因为它的行为和现实世界中的表面最接近。
名称 | 说明 |
---|---|
DiffuseMaterial | 创建平滑的无光泽表面,在各个方向上均匀地散射光线 |
SpecularMaterial | 创建有光泽的、高亮度的外观(就像金属和玻璃)。直线反向反射光线,像一面镜子 |
EmissiveMaterial | 创建发光的外观,产生自己的光线(尽管这些光线不能从场景中的其他对象反射回来) |
MaterialGroup | 通过该属性可组合多种材质,然后使用添加到MaterialGroup中的顺序重叠材质 |
DifuseMaterial类提供了 Brush属性,该属性获取希望用于绘制 3D 对象表面的 Brush 对象(如果使用除了 SolidColorBrush 画刷外的其他画刷,就需要设置 MeshGeometry3D.Texture Coordinates属性,定义将画刷映射到对象上的方式,就像将在本章后面看到的那样)。
下面演示了如何配置三角形,将其绘制为黄色的蒙版表面:
1 | <GeometryModel3D> |
光源
为创建逼真的已经着色的 3D对象,WPF 需要使用光照模型。基本概念是为 3D 场景添加个或多个光源,然后根据选择的灯光类型、灯光位置、灯光方向以及强度照亮对象。
在深入研究 WPF 光照前,知道 WPF 光照模型和真实世界中的光照行为是不同的,这一点很重要。尽管构造 WPF 光照系统是为了模拟真实世界,但计算真实的灯光反射是处理器密集型任务。WPF 进行了许多简化以保证光照模型是可行的,即使是在具有多个光源的不断变换的3D场景中也同样如此。这些简化包括以下几个方面:
- 分别为每个对象计算灯光效果。从一个对象反射的灯光不会影响另一个对象。类似地不管放在何处,一个对象不会在另一个对象上投射阴影。
- 为每个三角形的每个顶点进行灯光计算,然后在三角形的表面进行插值(换句话说,WPF决定了每个拐角的灯光强度,然后为了填充三角形混合这些灯光强度)。由于这种设计,只有很少几个三角形的对象可能无法被正确地进行照明。为得到更好的光照效果,需要将形状分成数百个或数千个三角形。
根据试图得到的效果,可能需要组合多个光源、使用不同的材质甚至添加额外的形状来解决问题。实际上,获得希望的精确结果是 3D 场景设计艺术的一部分。
即使没有提供光源,对象也依然可见。但如果没有光源,所看到只是纯黑色的轮廓。
WPF提供了4个灯光类,这4可类都继承自抽象的Light类。下表列出了这4可灯光类。这个示例将使用 DirectionalLight 光源,这是最常用的光源类型。
名称 | 说明 |
---|---|
DirectionalLight | 使用沿着指定方向传播的平行光线填充场景 |
AmbientLight | 使用散射的光线填充场景 |
PointLight | 从空间中的一点,向各个方向辐射的光线 |
SpotLight | 从一个点开始,以雏形向外辐射的光线 |
下面的代码说明了如何定义白色的DirectionalLight光源:
1 | <DirectionalLight Color="White" Direction="-1,-1,-1"/> |
在这个示例中,向量决定了光线的路径,从原点(0,0,0)发出并且经过点(-1,-1,-1)。这意味着每条光线都是从右上前方到左下后方的直线。在这个示例中这是合理的,因为三角形是面向这个光源的(如下图所示)。
在这个示例中,灯光的方向不是完全和三角形的表面对齐。如果希望灯光的方向和三角的表面对齐,就需要使用方向(0,0,-1)使光源发出的光线向Z轴反方向照射。这种区别是故意安排的。因为以一定角度照射三角形时,会着色三角形的表面,从而创建更美观的效果。
定向光在一定程度上类似于太阳光。因为来自遥远光源(如太阳)的光线几乎是平行的
下图近似地显示了方向为(-1,-1,-1)的灯光照射到三角形上的情况。请记住,定向光充满了整个 3D 空间。
所有灯光对象都间接地继承自GeometryModel3D类。这意味着可以像处理 3D 对象一样处理光源,将它们放到 ModelVisual3D对象中,并将它们添加到视口中。下面的视口同时包含了在前面看到的三角形和光源:
1 | <Viewport3D> |
摄像机
在渲染 3D场景前,需要在正确的位置放置摄像机,并使其朝向正确的方向。可通过使用Camera对象设置 Viewport3D.Camera 属性来完成该工作。
本质上,**摄像机确定了如何将 3D场景投影到 Viewport 对象的 2D 表面上。WPF 提供了三个摄像机类:常用的 PerspectiveCamera、更特殊的 OrthographicCamera和 MatrixCamera。**使用PerspectiveCamera 摄像机渲染场景,会使远处的对象看起来更小。这是在三维场景中大多数人所期望的行为。OrthographicCamera 摄像机平行投影3D 对象,使3D 对象保持相同的尺寸,而不管将形状放置在什么位置。这看起来有点古怪,但对于某些可视化工具这是很有用的。例如工艺绘图应用程序通常使用此类视图(下图显示了PerspectiveCamera 摄像机和 OrthographicCamera 摄像机之问的区别)。最后,可以通过 MatrixCamera 摄像机指定用于将 3D 场景变换到2D 视图的矩阵。它是一个高级工具,可用于实现更特殊的效果,移植其他架构(如 Direct3D)的代码需要使用这种类型的摄像机。
选择正确的摄像机比较容易,但放置和配置摄像机有点复杂。第一步是通过设置Position属性指定在 3D 空间中放置摄像机的位置。第二步是使用 LookDirection 属性设置一个 3D 向量,指示摄像机的方向。在典型的3D场景中,使用Position 属性将摄像机放在离某个拐角的不远处,然后使用 LookDirection 属性倾斜摄像机使其俯视视图。
摄像机的位置决定了在视口中显示的场景范围。摄像机越近,场景放得越大。此外,拉伸了视口以适应容器,视口内部的内容也相应地被缩放。例如,如果想创建充满窗口的视口,可通过改变窗口的尺寸来扩展或收缩场景,
需要协调设置 Position 和 LookDirection属性。如果使用 Position属性移动了摄像机,但没有使用 LookDirection属性在正确的方向上转回摄像机以进行补偿,就看不到在 3D 场景中创建的内容。为了确保正确地设置摄像机的方向,应选择一个希望从摄像机进行观察的点。然后可使用下面的公式计算观察方向:
1 | CameraLookDirection = CenterPointOfInterest - CameraPosition |
在三角形示例中,使用位置(-2,2,2)将摄像机放到左上角。假定希望聚焦在原点(0,0,0),该点位于三角形底边的中点,应当使用下面这个观察方向:
1 | CameraLookDirection = (0, 0, 0) - (-2, 2, 2) = (2, -2, -2) |
这个方向相当于法线向量(1,-1,-1),因为它们描述的方向是相同的。与 DirectionalLight类的 Direction 属性一样,重要的是向量的方向,而其长度并不重要。
一日设置 Position 和 LookDirection 属性,可能还希望设置 UpDirection 属性。UpDirection属性决定了摄像机的倾斜角度。通常将UpDirection属性设置为(0.1.0),这意味着向量垂直向上,如下图所示。
如果稍微偏移一下——比如说(0.25,1,0)——摄像机就会转向 x轴,如下图所示。所以,3D 对象看起来就会转向相反的方向,就像当俯视场景时转动头部一样。
一以下示例显示了完整的细节
1 | <Viewport3D> |
**摄像机类还提供了 NearPlaneDistance 和 FarPlaneDistance 属性,这两个属性用于设置盲区。**比 NearPlaneDistance 更近的对象根本不会显示,并且比 FarPlaneDistance 更远的对象同样是不可见的。通常,NearPlaneDistance属性默认设置为0.125,而FarPlaneDistance 属性默认设置为Double.Positivelnfinity,这会同时渲染那些微不足道的效果。然而在某些情况下,需要改变这些值以防出现渲染伪影(rendering artifacts)。最常见的例子是当复杂的网格离摄像机非常近时,这可能会导致 z-fighting 问题(也称为拼接)。在这种情况下,显卡不能正确地确定对于摄像机而言哪个三角形是最近的,以及是否应当渲染。结果会在网格表面上造成伪影问题。
z-fighting 问题通常是由于显卡上浮点数的舍入错误造成的。为避免这一问题,可增加NearPlaneDistance 属性的值,以剪裁那些离摄像机非常近的对象。本章后面将列举一个示例,移动摄像机使其飞过环形曲面的中心。为创建这种效果而又不导致z-fighting 问题,就需要增加NearPlaneDistance 属性的值。
深入研究3D绘图
为渲染简单三角形,分析摄像机、灯光、材质以及网格图形也需要做很多工作。至此,您已经看到了 WPF 对 3D支持的基本框架。本节将学习如何使用这个基本框架构建更复杂的形状。
构建立方体面临的第一个挑战是决定如何将它分割为MeshGeometry 对象能够识别的三角形。每个三角形就像平面化的 2D 形状。
立方体由6个正方形侧面组成。每个正方形侧面需要两个三角形。然后每个正方形侧面就可以按一定角度在相邻的侧边进行连接。下图显示了如何将立方体分割成三角形。
为降低开销并提高性能,在 3D程序中通常避免渲染看不到的形状。例如,如果知道永远不会看到下图中显示的立方体的下侧,就不需要为该侧定义两个三角形。然而,在本例中定义了每一侧,从而可以自由地旋转立方体。
为降低开销并提高性能,在3D 程序中通常避免渲染看不到的形状。例如,如果知道永远不会看到上图中显示的立方体的下侧,就不需要为该侧定义两个三角形。然而,在本例中定义了每一侧,从而可以自由地旋转立方体。
下面是创建立方体的
MeshGeometry3D
对象
1 | <MeshGeometry3D |
首先,Positions集合定义了立方体的拐角。首先是背面(对于背面,2-0)的4个点,然后是前面(对于前面,z=10)的4个点。TriangleIndices属性将这些点映射到三角形。例如,集合中的第一项是“0,2,1”。这创建了一个三角形,从第一个点(0,0,0)到第二个点(0,0,10),再到第三个点(0,10,0)。这个三角形位于立方体的后侧面(索引“1,2,3”填充另一个后侧面三角形)。
请记住,当定义三角形时,必须以逆时针顺序定义它们,从而使它们的前面面向前。然而这个立方体明显违背了这一规则。前侧面正方形上的三角形是以逆时针顺序定义的(例如,查看索引“4,5,6”和“7,6,5”),而那些在背面上的三角形是以顺时针顺序定义的,包括索引“02.1”和“1,2,3”。这是因为立方体后侧面上的三角形必须面向后面。为更好地理解该问题,设想绕Y轴旋转立方体,从而使后侧面面向前方。现在,面向后方的三角形将面向前方,从而使它们完全可见,这正是我们所希望的行为。
着色和法线
刚才演示的立方体存在一个问题。它不能创建上图中显示的含有侧面的立方体。相反它给出的是下图中显示的立方体,在三角形汇合处具有明显的接缝。
这个问题是由于 WPF 计算光照的方式造成的。为简化计算过程,WPF 计算到达形状中每个顶点的光线数量–换句话说,只关注三角形的拐角。然后在三角形的表面混合光照。虽然这可以保证每个三角形被很好地着色,但会引起其他伪影问题。例如,在这种情况下会阻止共享同一个立方体侧面的相邻三角形均匀地着色。
为理解为什么会产生这个问题,需要知道有关法线的更多内容。每个法线定义了顶点如何面向光源。大多数情况下,我们希望法线垂直于三角形平面。