高性能 WPF 3D 图表
关于 WPF 3D 性能增强技术的文章。

引言
在使用 WPF 进行 3D 图形绘制时,许多人对性能表示担忧。遵循 Microsoft 在线帮助的指导方针,我构建了一个 3D 曲面图,如图所示。该曲面图拥有超过 40,000 个顶点和超过 80,000 个三角形。性能仍然很好。该项目还包括具有大量数据点的 3D 散点图。您可以构建该项目,感受 WPF 3D 的性能,并决定 WPF 3D 是否适合您的 3D 数据可视化。
1. 基本 3D 设置
本节将简要介绍使用 WPF 构建 3D 图形的步骤。尽管有许多关于 WPF 3D 的教程,但我仍在此进行简要回顾,以帮助理解该项目的类结构。
WPF 3D 显示在 Viewport3D
UI 元素中。三个基本组件是
- 相机
- 光线
- 3D 模型
对于 3D 图表,我们不太关心摄像机和灯光。这些属性在 XAML 文件中设置,如下所示。3D 模型将在 C# 代码中设置。
<Window x:Class="WPFChart.Window1"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="WPF 3D Chart" Height="500" Width="600">
<Grid>
<Viewport3D Name="mainViewport" >
<Viewport3D.Camera>
<OrthographicCamera x:Name="camera"
FarPlaneDistance="10"
NearPlaneDistance="1"
LookDirection="0,0,-1"
UpDirection="0,1,0"
Position="0,0,2" />
</Viewport3D.Camera>
<Viewport3D.Children>
<ModelVisual3D x:Name="Light1">
<ModelVisual3D.Content>
<DirectionalLight Color="White" Direction="1,1,-1"/>
</ModelVisual3D.Content>
</ModelVisual3D>
</Viewport3D.Children>
</Viewport3D>
</Grid>
</Window>
根元素是 Window
。在 window
中,我们使用 Grid
布局。这两个元素是在我们构建项目时由 Visual Studio 提供的。在 grid
中,我们添加一个 Viewport3D
来容纳 3D 对象。在 Viewport3D
下,我们有一个摄像机和一个方向光。
我们在 XAML 文件中添加了摄像机和灯光。现在,我们在 C# 代码中添加 3D 模型。网格结构(类型 System.Windows.Media.Media3D.MeshGeometry3D
)包含四个数据部分
- 顶点位置
- 顶点之间的连接
- 顶点的法线方向
- 每个顶点的纹理映射坐标
顶点位置由 Point3D
结构表示。
System.Windows.Media.Media3D.Point3D point0 = new Point3D(-0.5, 0, 0);
System.Windows.Media.Media3D.Point3D point1 = new Point3D(0.5, 0.5, 0.3);
System.Windows.Media.Media3D.Point3D point2 = new Point3D(0, 0.5, 0);
这些点被放入网格结构的 Positions
数组中。
System.Windows.Media.Media3D.MeshGeometry3D triangleMesh = new MeshGeometry3D();
triangleMesh.Positions.Add(point0);
triangleMesh.Positions.Add(point1);
triangleMesh.Positions.Add(point2);
三个顶点构成一个三角形。顶点连接由三个整数描述,它们是 Positions
数组中 3 个顶点的索引。
int n0 = 0;
int n1 = 1;
int n2 = 2;
一个三角形的 3 个索引被添加到 TriangleIndices
数组中。
triangleMesh.TriangleIndices.Add(n0);
triangleMesh.TriangleIndices.Add(n1);
triangleMesh.TriangleIndices.Add(n2);
索引的顺序决定了三角形是正面还是反面。正面和反面通常具有不同的属性。WPF 3D 显示还需要知道顶点的法线方向。
System.Windows.Media.Media3D.Vector3D norm = new Vector3D(0, 0, 1);
triangleMesh.Normals.Add(norm);
triangleMesh.Normals.Add(norm);
triangleMesh.Normals.Add(norm);
我们将在后面的部分讨论纹理映射。上面的代码只显示了一个三角形。通过组合许多三角形,我们可以得到一个网格结构。现在,我们将为网格表面附加材质属性。
System.Windows.Media.Media3D.Material frontMaterial =
new DiffuseMaterial(new SolidColorBrush(Colors.Blue));
组合网格和材质,我们可以得到一个 3D 模型。
System.Windows.Media.Media3D.GeometryModel3D triangleModel =
new GeometryModel3D(triangleMesh, frontMaterial);
GeometryModel3D
对象还有一个 transform
属性。我们将在下一节中讨论它。
triangleModel.Transform = new Transform3DGroup();
我们创建的 3D 模型将被附加到一个可视元素
System.Windows.Media.Media3D.ModelVisual3D visualModel = new ModelVisual3D();
visualModel.Content = triangleModel;
ModelVisual3D
对象将被显示在 Viewport3D
中
this.mainViewport.Children.Add(visualModel);
这涉及很多步骤。该项目中的 Model3D
类有助于生成 ModelVisual3D
对象。如果我们运行程序,我们将看到一个蓝色的三角形。我们还不能旋转它。在下一节中,我们将展示如何旋转这个 3D 模型。
2. 旋转 3D 模型
本节我们将使用鼠标来旋转 3D 模型。在 WPF 中旋转 3D 模型很简单,但我们稍后需要实现自己的选择功能。因此,我们在旋转 3D 模型时需要跟踪 transform
。
为了捕获鼠标事件,我们用透明的 Canvas
覆盖 Viewport3D
。Canvas 的鼠标按下、移动和释放事件处理程序将被添加到 window
类中。
我们可以改变摄像机位置,或者改变 3D 模型的 transform
属性来旋转 3D 对象。对于本项目,我们将修改 3D 模型的 transform
属性。3D 模型的变换属性可以描述为 System.Windows.Media.Matrix3D
。我们将构建一个特殊的变换类来使用这个矩阵。
public class TransformMatrix
{
public Matrix3D m_viewMatrix = new Matrix3D();
private Point m_movePoint;
}
Matrix3D
成员变量 m_viewMatrix
用于旋转 3D 对象。TransformMatrix
类将处理鼠标事件并旋转模型。
public class TransformMatrix
{
public void OnMouseMove(Point pt, System.Windows.Controls.Viewport3D viewPort)
{
double width = viewPort.ActualWidth;
double height = viewPort.ActualHeight;
if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
{
}
else
{
double aY = 180 * (pt.X - m_movePoint.X) / width;
double aX = 180 * (pt.Y - m_movePoint.Y) / height;
m_viewMatrix.Rotate(new Quaternion(new Vector3D(1, 0, 0), aX));
m_viewMatrix.Rotate(new Quaternion(new Vector3D(0, 1, 0), aY));
m_movePoint = pt;
}
}
}
3D 旋转在鼠标移动事件中实现。视图矩阵将根据当前鼠标位置与前一个鼠标位置 m_movePoint
的偏移量进行旋转。我们进行缩放旋转,以便当我们从窗口的一侧移动鼠标到另一侧时,模型移动 180 度。您可以更改此旋转灵敏度。
要使用 TransformMatrix
类,可以在窗口类中添加一个 TransformMatrix
变量,并在窗口类的相应鼠标事件中调用 TransformMatrix
对象的鼠标事件处理程序。
public partial class Window1 : Window
{
public WPFChart.TransformMatrix m_transformMatrix = new WPFChart.TransformMatrix();
public void OnViewportMouseMove(object sender,
System.Windows.Input.MouseEventArgs args)
{
Point pt = args.GetPosition(mainViewport);
if (args.LeftButton == MouseButtonState.Pressed)
{
m_transformMatrix.OnMouseMove(pt, mainViewport);
Transform3DGroup group1 = triangleModel.Transform as Transform3DGroup;
group1.Children.Clear();
group1.Children.Add(new MatrixTransform3D(transformMatrix.m_ m_viewMatrix));
}
}
}
修改 transform
矩阵后,我们需要将新的视图矩阵设置到 3D 模型的 transform
属性。
3. 自动缩放
我们在前两节中使用的三角形的数据范围是 -0.5 ~ 0.5。我们使用的摄像机默认宽度为 2。摄像机中心位于 (0, 0)。因此,三角形在摄像机的视图范围内。如果 3D 对象超出摄像机范围,它将不会显示在 Viewport3D
中。我们可以改变摄像机位置来使 3D 对象保持在摄像机视图中。这里,我们采用另一种方法。我们将添加另一个矩阵将 3D 对象投影到摄像机视图范围内。
public class TransformMatrix
{
private Matrix3D m_viewMatrix = new Matrix3D();
private Matrix3D m_projMatrix = new Matrix3D();
public Matrix3D m_totalMatrix = new Matrix3D();
}
投影矩阵会将 3D 模型变换到摄像机视图范围内。然后,3D 对象会经过视图矩阵,如前一节所述。总变换矩阵将被设置到 3D 模型变换中。
投影矩阵由 3D 对象的数据范围设置。
public class TransformMatrix
{
public void CalculateProjectionMatrix(double xMin, double xMax,
double yMin, double yMax, double zMin, double zMax, double k)
{
double xC = (xMin + xMax) / 2;
double yC = (yMin + yMax) / 2;
double zC = (zMin + zMax) / 2;
m_projMatrix.SetIdentity();
m_projMatrix.Translate(new Vector3D(-xC, -yC, -zC));
double sX = k*2 / (xMax - xMin);
m_projMatrix.Scale(new Vector3D(sX, sX, sX));
m_totalMatrix = Matrix3D.Multiply(m_projMatrix, m_viewMatrix);
}
}
函数的最后一个参数是缩放因子。值为 0.5 意味着我们希望数据占据屏幕的 50%。每次更改视图矩阵或投影矩阵时,都需要计算总变换矩阵。我们还需要更改 window
类中的代码。我们不再将视图矩阵 m_viewMatrix
设置到 3D 模型变换属性,而是将总变换矩阵 m_totalMatrix
设置到 3D 对象变换中。
4. 3D 选择
WPF 提供了鼠标命中测试功能。但它可能不适合 3D 图表。例如,3D 散点图可能有数千个数据点。对这些数据点运行命中测试在性能方面并不实际。因此,我们应该关闭 Viewport3D
的 IsHitTestVisible
属性。
为了实现我们自己的选择功能,我们需要知道 3D 点在 2D 屏幕上的投影位置。在前一节中,我们为 3D 对象添加了一个矩阵变换。除了这个变换之外,3D 对象在投影到 2D 屏幕之前还会经历其他变换。例如,摄像机有自己的变换。我们使用的正交摄像机保持默认宽度为 2。它指向 –z 方向。您可以检查摄像机变换矩阵,会发现它是一个单位矩阵。因此,我们将忽略摄像机变换。但是,我们还有一个尚未讨论的变换,即将摄像机范围投影到 Viewport3D
的最终变换。
- 摄像机的中心 (0, 0) 被投影到
Viewport3D
的中心 (w/2, h/2)。 transform
的缩放因子仅由 x 轴决定。y 轴与 x 轴具有相同的缩放。- 摄像机的 y 轴向上,而
Viewport3D
的 y 轴向下。
遵循这些规则,TransformMatrix
类中的 VertexToScreenPt()
函数计算 3D 点在屏幕上的位置。
public class TransformMatrix
{
public Point VertexToScreenPt(Point3D point,
System.Windows.Controls.Viewport3D viewPort)
{
Point3D pt2 = m_totalMatrix.Transform(point);
double width = viewPort.ActualWidth;
double height = viewPort.ActualHeight;
double x3 = width / 2 + (pt2.X) * width / 2;
double y3 = height / 2 - (pt2.Y) * width / 2;
return new Point(x3, y3);
}
}
输入的 Point3D
参数是一个点的 3D 位置,返回的 Point
值是它在 2D 屏幕上的位置。要测试此功能,我们将鼠标移到三角形的一个角上,并将实际屏幕坐标与 VertexToScreenPt()
函数预测的位置进行比较。
在窗口底部添加了一个文本块,作为状态栏。在鼠标移动事件中,我们可以按住鼠标左键并将三角形旋转到不同的位置,就像我们在前一节中所做的那样。然后,我们可以将鼠标移到三角形的右上角。实际鼠标位置可以从鼠标事件参数中获取。我们将此读数与三角形顶点的计算位置进行比较。
public partial class Window1 : Window
{
public void OnViewportMouseMove(object sender,
System.Windows.Input.MouseEventArgs args)
{
Point pt = args.GetPosition(mainViewport);
if (args.LeftButton == MouseButtonState.Pressed)
{
}
else
{
String s1;
Point pt2 = m_transformMatrix.VertexToScreenPt
(new Point3D(0.5, 0.5, 0.3), mainViewport);
s1 = string.Format("Screen:({0:d},{1:d}), Predicated:({2:d},
H:{3:d})", (int)pt.X, (int)pt.Y, (int)pt2.X, (int)pt2.Y);
this.statusPane.Text = s1;
}
}
}
查看状态栏显示,我们知道 TransformMatrix.VertexToScreenPt()
函数返回了正确的屏幕位置。我们可以将三角形旋转到不同的位置,并仍然得到匹配的结果。基于 TransformMatrix.VertexToScreenPt()
函数,我们在本项目中实现了 select
功能。
理解屏幕变换也有助于我们在鼠标移动事件中实现拖动功能。当按下 shift 键时,鼠标将用于拖动 3D 模型。我们希望 3D 模型在屏幕上移动相同的距离。因此,我们在拖动模型时使用摄像机宽度与 Viewport3D
宽度的比率作为缩放因子。
public class TransformMatrix
{
public void OnMouseMove(Point pt, System.Windows.Controls.Viewport3D viewPort)
{
double width = viewPort.ActualWidth;
double height = viewPort.ActualHeight;
if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
{
double shiftX = 2 *(pt.X - m_movePoint.X) /( width);
double shiftY = -2 *(pt.Y - m_movePoint.Y)/( width);
m_viewMatrix.Translate(new Vector3D(shiftX, shiftY, 0));
m_movePoint = pt;
}
m_totalMatrix = Matrix3D.Multiply(m_projMatrix, m_viewMatrix);
}
}
5. 项目的基本类
在前一节中,我们显示了一个三角形。3D 对象由许多三角形组成。Mesh3D
和 ColorMesh3D
类分别用于单色 3D 模型和彩色 3D 模型。对于单色 3D 对象,我们有 Mesh3D
类
public class Mesh3D
{
private Point3D [] m_points; // x, y, z coordinate
private Triangle3D [] m_tris; // triangle information
private Color m_color; // mesh color
public double m_xMin, m_xMax, m_yMin, m_yMax, m_zMin, m_zMax;
}
单色网格模型由一个 3D 点数组和一个三角形顶点索引数组组成。整个网格模型具有一个单一颜色,该颜色由第三个成员变量 m_color
描述。最后六个成员变量是网格模型的数据范围。
Triangle3D
类定义了三角形的顶点索引。
public class Triangle3D
{
public int n0, n1, n2;
}
基于 Mesh3D
类,我们可以构建不同的基本形状,例如立方体、圆柱体、圆锥体和球体。它们是 Mesh3D
类的子类。这些基本形状在 3D 图表中是必需的。
Mesh3D
类用于数据处理。我们必须将其转换为 WPF ModelVisual3D
类型才能进行 3D 显示。我们还希望将不同的网格模型合并成一个单一的 3D 模型以提高 3D 显示的性能。Model3D
类就是为此目的而设计的。下图描述了该项目的类结构。
不同 3D 图表中的 3D 数据形式不同。本项目仅演示散点图和曲面图。它们分别由 ScatterChart3D
和 SurfaceChart3D
类表示。3D 图表数据在传递给 Viewport3D
之前会经过几次转换。首先,它生成一个 Mesh3D
(或 ColorMesh3D
)对象数组。然后将此 Mesh3D
数组传递给 Model3D
类,生成一个单一的 ModelVisual3D
对象。ModelVisual3D
对象被添加到 Viewport3D
中进行显示。
6. 彩色 3D 模型
WPF 3D 模型可以使用画笔设置颜色。如果一个 3D 图表有很多彩色对象,创建许多不同颜色的画笔会降低性能。相反,我们可以创建一个图像画笔用于颜色映射。图像在不同位置有不同的颜色。我们可以为不同的颜色使用不同的映射坐标。
真实颜色有 256^3 = 16777216 种颜色。这些颜色需要一个 4096x4096 的映射图像。通常,3D 图表只使用有限数量的颜色。对于不同的 3D 图表,我们将使用不同的颜色布局。对于条形图和散点图,我们在每个通道中使用 16 种颜色值。将有 16^3 = 4096 种颜色。这应该足以标记不同的类别。映射图像的大小将是 64x64。
对于曲面图,我们通常根据 3D 图的 z 值进行伪彩色处理,如本文第一张图所示。下图显示了我们用于伪彩色的颜色映射方法。x 轴是归一化的 z 值。y 轴显示与 z 值对应的 RGB 颜色。

TextureMapping
类实现了两种颜色布局。这里,我们只讨论散点图的颜色布局。您可以查看 TextureMapping
类的源代码以了解伪彩色映射。映射图像的大小为 64x64。蓝色通道有 16 个值,因此蓝色通道占每行的 1/4。然后我们更改绿色通道。对于每个红色值,绿色和蓝色值占用 4 行。
WritableBitmap
将在图像画笔中使用。
public class TextureMapping
{
public DiffuseMaterial m_material;
private void SetRGBMaping()
{
WriteableBitmap writeableBitmap =
new WriteableBitmap(64, 64, 96, 96, PixelFormats.Bgr24, null);
writeableBitmap.Lock();
首先,我们设置一个 64x64 的 RGB 位图。为了访问位图内存,我们需要锁定位图。
unsafe
{
byte* pStart = (byte*)(void*)writeableBitmap.BackBuffer;
int nL = writeableBitmap.BackBufferStride;
for (int r = 0; r < 16; r++)
{
for (int g = 0; g < 16; g++)
{
for (int b = 0; b < 16; b++)
{
int nX = (g % 4) * 16 + b;
int nY = r*4 + (int)(g/4);
*(pStart + nY*nL + nX*3 + 0) = (byte)(b * 17);
*(pStart + nY*nL + nX*3 + 1) = (byte)(g * 17);
*(pStart + nY*nL + nX*3 + 2) = (byte)(r * 17);
}
}
}
}
为了直接访问位图内存,我们需要使用不安全代码。我们还需要在项目设置中启用不安全模式。对于每个通道,我们使用 16 个级别。位图中的像素位置根据 RGB 值计算。设置相应像素的颜色。
writeableBitmap.AddDirtyRect(new Int32Rect(0, 0, 64, 64));
writeableBitmap.Unlock();
ImageBrush imageBrush = new ImageBrush(writeableBitmap);
imageBrush.ViewportUnits = BrushMappingMode.Absolute;
m_material = new DiffuseMaterial();
m_material.Brush = imageBrush;
}
}
设置完颜色像素后,我们设置脏标志,以便 WPF 更新位图元素。完成位图内存访问后,我们需要解锁位图,以便 WPF 可以更新位图显示。然后,我们使用位图创建一个图像画笔。最后,我们使用图像画笔创建一个材质。
稍后,当我们使用映射图像进行颜色绘制时,我们需要知道某种颜色的映射位置。这由 TextureMapping
类的 GetMappingPosition()
函数提供。
public class TextureMapping
{
public Point GetMappingPosition(Color color)
{
int r = (color.R) / 17;
int g = (color.G) / 17;
int b = (color.B) / 17;
int nX = (g % 4) * 16 + b;
int nY = r * 4 + (int)(g / 4);
return new Point((double)nX /63, (double)nY /63);
}
}
要使用映射图像进行颜色绘制,我们获取每个顶点的颜色,然后找到该颜色的映射坐标,并将映射坐标添加到 MeshGeometry3D
对象的 TextureCoordinates
数组中。这在 Model3D
类的 SetModel()
函数中实现。
Using the Code
本项目为高性能 3D 图表提供了一些基本类。它不是一个完整的库。您仍然需要添加更多类来显示网格、标签、标题。下图显示了测试程序。数据在一定范围内随机生成。因此,看起来并不真实。您可以插入您自己的数据。查看 window
类中的代码,了解如何使用这些类。

本项目提供了以下功能
- 生成用于显示的 3D 模型。
检查“测试”按钮的消息处理程序,了解如何为 3D 图表生成显示模型。
- 旋转 3D 模型。
按住鼠标左键旋转 3D 模型。
- 拖动 3D 模型。
按住鼠标左键和 shift 键拖动模型。
- 缩放
按“+”或“-”键进行缩放。
- Select
使用鼠标右键绘制矩形并在 3D 图表中选择数据。
最后,您可以更改数据数量(然后单击测试按钮)来测试 WPF3D 的性能。
关注点
WPF 非常容易用于显示一些简单的对象。但是,如果要显示大量数据,则需要做很多工作。这些性能增强任务看似微不足道,但需要大量的测试和调试。我希望本项目能帮助您决定是否为您的 3D 数据显示使用 WPF,还是继续使用 OpenGL 等其他技术。
历史
- 2009 年 9 月 7 日:初始发布