WPF 3D 入门






4.97/5 (85投票s)
探索 Windows Presentation Foundation 3D 功能,

引言
在过去的几天里,我不得不评估构建一个图形用户界面(GUI)的可能性,该界面向用户显示一个实心形状,并允许用户执行一些基本操作,尤其是旋转和缩放视图。
我对 3D 图形不是专家,这是我第一次接触这个领域,而且我从未使用过 Direct3D 或 OpenGL,所以一切都必须极其简单。
WPF 3D 似乎是构建 3D 界面的最快方法,因此我决定创建一个小型应用程序,该程序仅能让用户旋转和缩放单一实心对象的视图。
尽管我对结果非常满意,但我在这里可能犯了一些新手错误,所以请随意指出。
要求
为了构建此应用程序,我们需要
- Visual Studio 2008 Professional(Visual C# 2008 Express 应该足够)
- .NET Framework 3.5(与 Visual Studio 一同安装)
- WPF 基础知识
- 三角学基础知识
如果您是 WPF 新手,我强烈建议您阅读 Josh Smith 的精彩文章(WPF 导览)。您还可以查看 MSDN 入门文章 以及侧重于 WPF 中的 3D 图形 的部分。
步骤 0 - 基础知识
关于 WPF 3D,只有几点需要了解
- 它基于 Direct3D,因此它利用了图形卡(就像 WPF 2D 所做的那样)
- 在 Windows XP 上,默认禁用全屏抗锯齿;在 Windows Vista 上,默认启用全屏抗锯齿(如果图形卡驱动程序 符合 WDDM 标准)
然而,最重要的一点是 WPF 3D 使用的坐标系不同,如下图所示

这种差异要求大多数与用户的交互(特别是鼠标事件)执行坐标转换。正如我们稍后将看到的,此操作实际上非常简单。
步骤 1 - 创建 Visual Studio 项目
这一步很简单,您只需要在 Visual Studio 中创建一个新的 WPF 应用程序项目,目标是 .NET Framework 3.5。请看下图

步骤 2 - 准备主窗口
修改项目向导为您创建的 Window1.xaml 文件,将窗口标题和尺寸设置为您喜欢的任何内容。由于这是一个测试应用程序,我们不太关心对象名称等,但我将主窗口文件重命名为 MainWindow.xaml,并检查所有引用是否正确(尤其是在自动创建的 App.xaml 文件中)。
注意:在编写 XAML 时,我们将仅为我们希望从代码隐藏中以编程方式访问的元素添加 x:Name
属性,而将所有其他元素保持未命名。
<Window x:Class="Wpf3DTest.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF 3D Test"
Height="400" Width="400">
<Grid>
</Grid>
</Window>
WPF 3D 基于 Viewport3D
UI 元素,该元素负责在屏幕上渲染 3D 场景,因此我们肯定需要该对象的一个实例。
我们还希望能够在使用户旋转或缩放场景后将视图重置到其原始“状态”,因此我们还需要一个按钮。
为了构建窗口布局,我们将使用一个包含 2 行的网格。第二行将由 WPF 自动调整大小以占据尽可能多的垂直空间,而第一行将仅包裹其内容。现在我们可以添加所需的 Viewport3D
和 Button
。
<Grid Background="Black">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Button x:Name="button" Grid.Row="0" Content="Reset" />
<Viewport3D x:Name="viewport" Grid.Row="1">
</Viewport3D>
</Grid>
上面的 XAML 将产生类似以下内容的结果(注意黑色背景的网格、顶部的按钮以及填充窗口的空白 viewport
)

步骤 3 - 相机
WPF 中的每个 3D 场景都应该有一个相机,否则它将不会在屏幕上渲染。由于我们不需要在运行时添加或删除它,因此我们可以只使用一点 XAML。
<Viewport3D.Camera>
<PerspectiveCamera x:Name="camera" FarPlaneDistance="50"
NearPlaneDistance="0" LookDirection="0,0,-10" UpDirection="0,1,0"
Position="0,0,5" FieldOfView="45" />
</Viewport3D.Camera>
上面的 XAML 在 Viewport3D
元素内添加了一个 PerspectiveCamera
(尽管还有其他类型的相机)。FarPlaneDistance
和 NearPlaneDistance
表示相机将显示元素的范围。当元素离相机太远或太近时,它将不会被显示。在大多数情况下,FieldOfView
可以安全地设置为 45
,这为我们提供了自然的透视视图。LookDirection
是相机“看向”的点:我们将其设置为具有负 Z 坐标的点。UpDirection
是相机的垂直轴:我们将其设置为与 Y 轴重合。
步骤 4 - 准备 3D 模型
viewport
的 content
使用 ModelVisual3D
UI 元素的实例进行描述(MSDN),该元素继承自抽象类 Model3D
。在此对象内部,我们基本上可以添加几何体(包括它们的材质)和灯光。由于我们的应用程序可能需要从外部源加载 3D 模型,因此我们现在仅使用 XAML 添加灯光,将其余工作留给代码隐藏。
WPF 中有 3 种光源:我们现在将添加其中两种,如下面的标记所示
<ModelVisual3D x:Name="model">
<ModelVisual3D.Content>
<Model3DGroup x:Name="group">
<AmbientLight Color="DarkGray" />
<DirectionalLight Color="White" Direction="-5,-5,-7" />
</Model3DGroup>
</ModelVisual3D.Content>
</ModelVisual3D>
模型中的实际内容在 ModelVisual3D.Content
元素内指定。由于我们有多个项目,因此我们使用 Model3DGroup
元素(MSDN)将它们全部包装起来。上面的 XAML 添加了一个暗灰色(即非常昏暗的灯光)AmbientLight
和一个白色的 DirectionalLight
,它指向点 (-5, -5, -7)(定向灯没有位置,它们只是“看向”指定的方向)。
我们使用的两种光源为场景提供了良好的照明,但请随时尝试新的配置。
网格基础
网格(Mesh)是一个仅使用三角形构建的 3D 对象。每个三角形显然有三个 3D 顶点,它们组合在一起形成一个称为面的小表面。
在 WPF 3D 中(也许还有 Direct3D 和/或 OpenGL,如我所说我不是专家),一个面有一个方向,它定义了面的可见侧,如下图所示

尽管您可以显式指定构成面的每个顶点的法线(而不是三角形)(使用 MeshGeometry3D
类的 Normals
属性),但 WPF 可以通过添加顶点的顺序自动计算它们。如果我们按逆时针顺序添加顶点,则面的方向将“朝向我们”,如上图所示:顶点标记为0、1和2,面的方向由标记为“+”的箭头表示。我们按0, 1, 2的顺序添加它们。
添加 3D 几何体
在我们的测试应用程序中,我们将从代码隐藏的 C# 文件中以编程方式添加 3D 对象。
我们将通过添加其 8 个顶点来创建下图所示的实体(一个截锥),然后定义构成其所有面的 12 个三角形。
我们在代码隐藏文件 MainWindow.xaml.cs(如果您没有重命名,则为 Window1.xaml.cs)中定义 BuildSolid
方法。该方法然后在类的构造函数中调用,它只是逐个添加顶点,按图中所示的顺序添加(这实际上不是必需的,但它使事情更清晰)。
// Define 3D mesh object
MeshGeometry3D mesh = new MeshGeometry3D();
// Front face
mesh.Positions.Add(new Point3D(-0.5, -0.5, 1));
mesh.Positions.Add(new Point3D(0.5, -0.5, 1));
mesh.Positions.Add(new Point3D(0.5, 0.5, 1));
mesh.Positions.Add(new Point3D(-0.5, 0.5, 1));
// Back face
mesh.Positions.Add(new Point3D(-1, -1, -1));
mesh.Positions.Add(new Point3D(1, -1, -1));
mesh.Positions.Add(new Point3D(1, 1, -1));
mesh.Positions.Add(new Point3D(-1, 1, -1));
尽管我们使用了“面”这个词,但值得注意的是,我们仍然没有定义任何实际的面。为了做到这一点,我们必须构建构成实体的每个面的三角形,从我们刚刚创建的点开始:我们通过以正确的顺序声明其顶点来添加每个三角形,这样每个面的方向都指向实体的外部。
// Front face
mesh.TriangleIndices.Add(0);
mesh.TriangleIndices.Add(1);
mesh.TriangleIndices.Add(2);
mesh.TriangleIndices.Add(2);
mesh.TriangleIndices.Add(3);
mesh.TriangleIndices.Add(0);
// Back face
mesh.TriangleIndices.Add(6);
mesh.TriangleIndices.Add(5);
mesh.TriangleIndices.Add(4);
mesh.TriangleIndices.Add(4);
mesh.TriangleIndices.Add(7);
mesh.TriangleIndices.Add(6);
// Right face
mesh.TriangleIndices.Add(1);
mesh.TriangleIndices.Add(5);
mesh.TriangleIndices.Add(2);
mesh.TriangleIndices.Add(5);
mesh.TriangleIndices.Add(6);
mesh.TriangleIndices.Add(2);
// Other faces (see complete source code)...
如您所见,我们使用了 8 个顶点定义了 12 个三角形。我们现在唯一需要做的就是将网格添加到场景中,将其包装在 GeometryModel3D
的实例中。
出于应用程序目的,我们将需要对几何体进行引用以供以后使用,因此我们在类中添加一个新的 private
成员。
// Reference to the geometry for later use
private GeometryModel3D mGeometry;
然后,我们可以使用以下代码完成 BuildSolid
方法,该代码创建几何体对象并将其添加到主窗口 XAML 中看到的 Model3DGroup
(命名为 group
)。
// Geometry creation
mGeometry = new GeometryModel3D(mesh, new DiffuseMaterial(Brushes.YellowGreen));
mGeometry.Transform = new Transform3DGroup();
group.Children.Add(mGeometry);
如您所见,我们使用了简单的 DiffuseMaterial
来为实体着色,但您可以查看 WPF 3D 在此领域提供的不同选项(请参阅 MSDN)。我们还将 Transform
属性设置为 Transform3DGroup
类的一个新实例,该类是变换对象的容器,正如我们稍后将看到的。
现在,如果我们按下 F5 键,我们应该会看到类似这样的内容

主窗口现在显示我们的测试实体,正面朝向用户。尝试调整窗口大小,看看 WPF 如何无缝地缩放 viewport
和实体。
步骤 5 - 实现缩放功能
为了简单起见,我们将仅通过鼠标滚轮来实现缩放功能。我们只需要为窗口中的主 grid
元素实现 MouseWheel
事件的处理程序。在这种情况下,IntelliSense 是我们的朋友
让 IntelliSense 弹出窗口出现,然后按 Enter。Visual Studio 应该已经创建了一个名为 Grid_MouseWheel
的处理程序。
该处理程序只需要做一件事:将我们的相机移动到 Z 轴上。将此移动与滚轮滚动值绑定相当棘手,在经过一些反复试验后,我发现以下代码效果很好
private void Grid_MouseWheel(object sender, MouseWheelEventArgs e) {
camera.Position = new Point3D(
camera.Position.X,
camera.Position.Y,
camera.Position.Z - e.Delta / 250D);
}
我们不更改相机的 X
和 Y
位置,我们只修改其在 Z
轴上的位置。
我们还希望让 XAML 中定义的 Reset 按钮重置相机位置,因此我们为它的 Click
事件添加一个处理程序(与我们为 grid
的 MouseWheel
事件添加处理程序的方式相同),并添加以下代码,该代码将相机位置的 Z 值重置为 5
private void Button_Click(object sender, RoutedEventArgs e) {
camera.Position = new Point3D(
camera.Position.X,
camera.Position.Y, 5);
}
万一您好奇,button
元素的 XAML 代码更新如下。
<Button x:Name="button" Grid.Row="0" Content=" Reset"Click="Button_Click" />
现在您可以再次按下 F5,验证缩放是否正常工作以及 Reset 按钮是否按预期工作。
步骤 6 - 实现 3D 旋转功能
我们现在希望允许用户通过鼠标旋转实体,非常像 CAD 应用程序。这是演示中最困难的部分,但只需一点三角学知识,我们应该就能很快解决。
此任务的难点在于将 2D 向量(鼠标移动)映射到实体的 3D 旋转。我花了一些时间才弄清楚,但鼠标移动向量可以轻松转换为旋转角度,该角度围绕一个与屏幕共面且垂直于鼠标移动向量的旋转轴应用于实体,如下图所示。

旋转轴固定在原点 (0,0,0),因为实体本身就居中在原点,并且鼠标移动向量和旋转轴的 Z
坐标都设置为零,因此它们与 XY
平面共面。
旋转轴有一个方向,由上图的箭头指示。角度始终以顺时针方向计算,因此如果您以正角度旋转实体,它将向左旋转

为了正确计算旋转轴,我们首先需要计算鼠标移动向量的角度。基本三角学告诉我们,角度 alpha 计算如下

我们还需要处理 dx
和 dy
(X 和 Y 方向的鼠标位置增量)的符号,正如我们将在代码中直接看到的。
为了实现旋转功能,我们需要为主 grid
的 MouseDown
、MouseUp
和 MouseMove
事件添加三个处理程序
<Grid Background="Black" MouseWheel="Grid_MouseWheel"
MouseDown="Grid_MouseDown" MouseUp="Grid_MouseUp"
MouseMove="Grid_MouseMove">
我们还需要在 MainWindow
类中定义一些变量(除了先前定义的 mGeometry
)
private GeometryModel3D mGeometry;
private bool mDown;
private Point mLastPos;
当按下鼠标左键时,mDown
为 true
,否则为 false
。mLastPos
包含鼠标指针相对于 viewport
的最后一个位置(这非常重要,因为我们将看到)。
MouseDown
和 MouseUp
事件处理程序非常简单,它们只是切换 mDown
。
private void Grid_MouseUp(object sender, MouseButtonEventArgs e) {
mDown = false;
}
private void Grid_MouseDown(object sender, MouseButtonEventArgs e) {
if(e.LeftButton != MouseButtonState.Pressed) return;
mDown = true;
Point pos = Mouse.GetPosition(viewport);
mLastPos = new Point(
pos.X - viewport.ActualWidth / 2,
viewport.ActualHeight / 2 - pos.Y);
}
MouseDown
实际上做了更多:它在按下鼠标左键后存储鼠标的第一个位置。该位置相对于 viewport
(Mouse.GetPosition
完成此操作),然后根据文章开头提到的,将其从 2D 坐标系转换为 3D 坐标系:X
和 Z
坐标必须使用 viewport
元素的 ActualWidth
和 ActualHeight
属性进行调整。
MouseMove
处理程序有很多工作要做。请看完整的代码
private void Grid_MouseMove(object sender, MouseEventArgs e) {
if(!mDown) return;
Point pos = Mouse.GetPosition(viewport);
Point actualPos = new Point(
pos.X - viewport.ActualWidth / 2,
viewport.ActualHeight / 2 - pos.Y);
double dx = actualPos.X - mLastPos.X;
double dy = actualPos.Y - mLastPos.Y;
double mouseAngle = 0;
if(dx != 0 && dy != 0) {
mouseAngle = Math.Asin(Math.Abs(dy) /
Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2)));
if(dx < 0 && dy > 0) mouseAngle += Math.PI / 2;
else if(dx < 0 && dy < 0) mouseAngle += Math.PI;
else if(dx > 0 && dy < 0) mouseAngle += Math.PI * 1.5;
}
else if(dx == 0 && dy != 0) {
mouseAngle = Math.Sign(dy) > 0 ? Math.PI / 2 : Math.PI * 1.5;
}
else if(dx != 0 && dy == 0) {
mouseAngle = Math.Sign(dx) > 0 ? 0 : Math.PI;
}
double axisAngle = mouseAngle + Math.PI / 2;
Vector3D axis = new Vector3D(
Math.Cos(axisAngle) * 4,
Math.Sin(axisAngle) * 4, 0);
double rotation = 0.02 *
Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2));
Transform3DGroup group = mGeometry.Transform as Transform3DGroup;
QuaternionRotation3D r =
new QuaternionRotation3D(
new Quaternion(axis, rotation * 180 / Math.PI));
group.Children.Add(new RotateTransform3D(r));
mLastPos = actualPos;
}
该方法首先检查鼠标左键是否被按下;如果不是,则退出。然后使用存储在 mLastPos
中的前一个位置计算鼠标 dx
和 dy
值,将当前位置转换为 3D 坐标系,如 Grid_MouseDown
事件处理程序所示。
然后使用我们之前看到的公式计算鼠标移动向量的角度,其中有三种情况
- 如果
dx
和dy
都不同于零,则使用dy
的绝对值计算mouseAngle
,然后根据dx
和dy
的符号进行校正。 - 如果
dx
为零,则mouseAngle
必须是 90 度或 270 度(我们使用dy
的符号)。 - 如果
dy
为零,则mouseAngle
必须是 0 度或 180 度(我们使用dx
的符号)。
旋转轴(axisAngle
)的角度是通过将鼠标移动向量角度增加 90 度来计算的。请记住,这些角度始终相对于 X
轴。

然后从 axisAngle
计算旋转 axis
(Vector3D
的实例),将 Z
坐标保留为零。
旋转 angle
计算为鼠标移动角度的模乘以 0.01
的因子,产生平滑的旋转
double rotation = 0.01 * Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2));
我们现在唯一需要做的就是对几何体应用变换。由于我们想旋转实体(但可以进行其他许多操作),因此我们需要将 RotateTransform3D
的实例添加到我们存储在几何体 Transform
属性中的 Transform3DGroup
集合中(请参阅 BuildSolid
方法)。我们将集合进行强制转换并将其存储在 group
变量中。
RotateTransform3D
的构造函数需要一个继承自 Rotation3D
的类的实例来描述要应用的旋转(请参阅 MSDN)。由于我们希望围绕轴以给定角度进行旋转,因此我们使用 QuaternionRotation3D
并用 axis
和 rotation
角度(以度为单位,而不是弧度)实例化它。
到目前为止,我省略了一个细节。旋转轴不足以定义实际的旋转轴,因为它还需要一个中心,在我们这里就是原点。RotateTransform3D
的构造函数接受一个可选参数,允许指定旋转中心。如果省略,则使用原点。
最后,我们可以将我们的变换添加到变换集合中
group.Children.Add(new RotateTransform3D(r));
再次,有一个基本细节需要注意。我们应用于实体的每个变换都是绝对的。这意味着如果我们按顺序应用两个变换,则后者将覆盖前者。出于这个原因,我们使用 Transform3DGroup
(继承自 Transform3D
)来存储后续变换,每次鼠标移动一个(正如您可以想象的那样,这构成了内存泄漏,但对于我们的测试目的,这不是问题)。
最后要做的事情是将当前鼠标位置存储在 mLastPos
变量中,以便下一次鼠标移动能够计算正确的 delta X
/Y
值。
我们现在可以更新 Reset 按钮的行为,使其除了重置相机位置外,还可以删除应用于实体上的所有变换(缓解我们上面提到的内存泄漏)。
private void Button_Click(object sender, RoutedEventArgs e) {
camera.Position = new Point3D(
camera.Position.X,
camera.Position.Y, 5);
mGeometry.Transform = new Transform3DGroup();
}
现在您可以再次按下 F5,看到最终结果。如果您运行的是 Windows Vista,您还可以欣赏应用于实体边缘的全屏抗锯齿滤镜。

结论
尽管要获得功能齐全的 3D 用户界面仍有许多工作要做,但我们探索了 WPF 3D 的一些有趣功能,并对其优点和问题有了一些了解。
历史
- 2008 年 1 月 31 日:初始帖子