65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的 3D 绘图库

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (168投票s)

2006年6月9日

CPOL

9分钟阅读

viewsIcon

466014

downloadIcon

13978

一个可以在任何 GDI+ Graphics 对象上绘制 3D 图像的库。

Screenshot

引言

本文介绍了一组类,允许您在三维坐标空间中绘制图形。在本文中,我将触及其中一些数学概念(不需要比高中三角学中学到的更多的知识),解释如何使用该库的基础知识,并列出我创建它所使用的资源。

背景

我最近对滚动的多米诺骨牌的运动以及可以描述它的数学产生了令人担忧的痴迷。我花了大约两周时间在餐巾纸和 ATM 收据的背面潦草地写计算,并将我办公室隔间的墙壁贴满了不断完善的问题描述和未完成的解决方案。一旦我开发出了一个有效的公式,我就决定在代码中实现它。这个库是我尝试制作一个滚动的多米诺骨牌动画的结果。

我的多米诺骨牌计算基于相对定位(“右转 45 度,然后前进 100 个单位”),而不是绝对定位(“移动到位置 [45, 12, 83]”)。像 LOGO 这样的语言非常适合这种绘图,我的库也具有类似的功能。但是,由于我们在处理三维空间,我的库允许您不仅左右转动光标,还可以上下转动光标(从屏幕中移出,朝向您)和向下(移入屏幕,远离您)。

建立方向

为了让光标在二维空间中移动,它需要了解其位置方向。位置可以用一个点 [x,y] 来确定,方向可以用一个向量来确定。向量确定了光标的前进方向,并用于计算左右方向。

然而,在三维空间中,情况有点棘手。光标的方向并不提供我们所需的所有信息。看看这个例子。这里有两架飞机,它们都朝同一方向飞行。每架飞机都有一个绿色指南针指向它认为是前进的方向,一个蓝色指南针指向它认为是右方的方向,一个紫色指南针指向它认为是上方的方向。

Airplane Right-side-upAirplane Up-side-down

这两架飞机都朝同一个方向前进,但如果让它们都向转,它们各自的行为都会不同。这就是为什么在三维空间中仅确定方向是不够的。在三维空间中移动时,我们需要建立方向。幸运的是,这非常简单。我们只需要一个额外的向量。除了指向我们认为是前进方向的向量外,我们还需要一个指向我们认为是向上方向的向量。一旦我们确定了两个方向,我们就可以利用它们来计算所有其他方向。

使用代码

您需要熟悉的第一门类是 Plotter3D。最简单的构造函数只接受一个 Graphics 对象。这可以是任何有效的 Graphics 对象,例如来自 FormBitmapMetafile 等。

using (Graphics g = this.CreateGraphics())
{
    using (CPI.Plot3D.Plotter3D p = new CPI.Plot3D.Plotter3D(g))
    {
        // Do some stuff with the plotter here
    }
}

创建 Plotter3D 对象后,您就可以开始绘图了。绘制正方形的方法如下:

public void DrawSquare(Plotter3D p, float sideLength)
{
    for(int i = 0; i < 4; i++)
    {
        p.Forward(sideLength);  // Draw a line sideLength long
        p.TurnRight(90);        // Turn right 90 degrees
    }
}

在此函数中,p.Forward(sideLength) 绘制正方形的一条边,p.TurnRight(90) 向右转 90 度。如果我们重复这个序列四次,我们就画出了一个正方形,并且会回到起点。该函数绘制的正方形如下所示:

Square

您可以多次调用 DrawSquare 来创建一个立方体

public void DrawCube(Plotter3D p, float sideLength)
{
    for (int i = 0; i < 4; i++)
    {
        DrawSquare(p, sideLength);
        p.Forward(sideLength);
        p.TurnDown(90);
    }
}

请注意,在此函数中,我们调用了 p.TurnDown(90)。这会在三维空间中转动光标,使其向远离您的方向移动,即进入计算机屏幕。当我们绘制四个不同位置的正方形时,我们会得到一个大致如下所示的立方体:

Cube

旋转

定义完一个形状后,旋转它就相当容易了。这是使用相对坐标而不是绝对坐标的一个好处。要绘制一个旋转的形状,请将光标移动到您想用作旋转中心的点,根据需要向左、向右、向上或向下转动光标,然后沿原路返回到起点,然后绘制您的形状。一个例子可能会有所帮助。让我们旋转一下我们的正方形:

public void DrawRotatedSquare(Plotter3D p, float sideLength, float rotationAngle)
{
    // Since we don't want to draw while repositioning ourselves at the
    // center of the object, we'll lift the pen up
    p.PenUp();

    // Move to the center of the square
    p.Forward(sideLength / 2);
    p.TurnRight(90);
    p.Forward(sideLength / 2);
    p.TurnLeft(90);

    // Now we rotate as much as we want
    p.TurnRight(rotationAngle);

    // Now we retrace our steps to get back
    // to the (rotated) starting point
    p.TurnLeft(90);
    p.Forward(sideLength / 2);
    p.TurnLeft(90);
    p.Forward(sideLength / 2);
    p.TurnRight(180);

    // Put the pen back down, so we start drawing again
    p.PenDown();

    // Finally we draw the square as we normally would
    DrawSquare(p, sideLength);
}

所以,如果我们调用

DrawRotatedSquare(p, 50, 45);

我们会得到类似这样的结果:

Rotated Square

使用这种技术,您可以生成多个图像,每个图像比上一个图像旋转一点,从而创建动画,例如:

Spinning Square

这种旋转逻辑也适用于三维空间,因此您可以随意使用它来旋转三维对象:

Spinning Cube 3

透视

在二维计算机屏幕上显示三维图像时,我们需要考虑透视。我无耻地借鉴了 Paresh Solanki 的精彩 CodeProject 文章:简要讨论了如何将三维对象映射到二维显示。在我的实现中,“屏幕”是由 X 和 Y 轴形成的平面,“摄像机”是 Plotter3D 对象构造函数中可以指定的 Point3D 实例。如果您不指定摄像机位置,它将默认为(完全任意的)位置 [-30, 0, -600]。尝试更改摄像机位置,看看它如何影响您的绘图。

可能会经常被问到的问题...

如何画一个圆?

简而言之……您不能。您只能绘制线条。更长的答案是……您可以绘制一个有很多边的多边形来相当令人信服地模拟它。下面是一个函数示例,它将绘制一个指定直径的圆的近似值:

private void DrawCircle(Plotter3D p, float diameter)
{
    float radius = diameter / 2;

    // Increasing this number will create a better approximation,
    // but will require more work to draw
    int sides = 64;

    float innerAngle = 360F / sides;

    float sideLength = (float)(radius * 
      Math.Sin(Orientation3D.DegreesToRadians(innerAngle) / 2) * 2);

    // Save the initial position and orientation of the cursor
    Point3D initialLocation = p.Location;
    Orientation3D initialOrientation = p.Orientation.Clone();

    // Move to the starting point of the circle
    p.PenUp();
    p.Forward(radius - (sideLength / 2));
    p.PenDown();

    // Draw the circle
    for (int i = 0; i < sides; i++)
    {
        p.Forward(sideLength);
        p.TurnRight(innerAngle);
    }

    // Restore the position and orientation to what they were before
    // we drew the circle
    p.Location = initialLocation;
    p.Orientation = initialOrientation;
}

这个函数有几个有趣的地方值得注意。首先,它实际上绘制了一个 64 边的多边形。如果您增加多边形的边数,它将绘制一个更接近圆的近似值,但您可能会发现大多数情况下 64 边就足够了。

您还会注意到,在绘制圆之前,我们保存了我们的位置和方向,并在绘制完圆之后恢复它们。原因是该库进行了大量浮点运算,而浮点运算是不精确的。因此,即使我们理论上应该在旋转 360 度后回到起点,我们实际上只能保证我们回到非常接近起点的位置。由于精度不足造成的误差非常小,但随着时间的推移可能会累积。因此,通过提前保存初始位置和方向,然后在之后恢复它们,我们可以保证我们的终点与起点完全相同。

最后,您会注意到,在保存初始位置时,我们只调用了 initialLocation = p.Location,但在保存初始方向时,我们调用了 initialOrientation = p.Orientation.Clone()。我们不必对 p.Location 调用 Clone(),因为 p.Location 是一个 Point3D,它是一个值类型,这意味着 initialLocation 被填充了值的副本。相反,p.Orientation 是一个引用类型,这意味着如果我们没有调用 Clone()initialOrientation 将指向与 p.Orientation 相同的对象,这不是我们想要的行为。Clone() 方法创建了 Orientation3D 对象的深拷贝。

让我们试试这个函数。

DrawCircle(p, 100);

绘制的圆看起来像这样:

Circle

考虑到所有因素,我觉得它看起来相当圆。我们到目前为止学到的所有旋转和动画技巧也适用于此对象,因此我们可以例如绘制一系列圆来创建一个球体,然后像这样围绕它旋转:

Spinning Sphere

如何绘制填充区域?

您不能。您只能绘制线条。一旦您开始做比线条更复杂的事情,您就需要开始考虑其他因素,例如可见性确定。这超出了本项目范围。

为什么您不提欧拉角、旋转矩阵或四元数?

因为,我真正需要这个项目做的就是绘制一些三维线条,这完全可以通过向量运算以及一些直接的代数和三角学来完成。当然,没有提到我没有谈论过的所有事情,三维图形的讨论就不完整了,但这个项目并不是为了成为一个完整的 3D 图形包。这只是一个易于使用的可视化和实验三维几何的方法。如果您正在寻找一个功能强大、特性齐全的 3D 图形库,可以看看 DirectX 或 OpenGL。

那么您最终有没有制作出多米诺骨牌动画,或者其他什么?

当然有。源代码包含在下载文件中,或者您可以在此处查看(导出到 Flash)的最终结果。

提示与技巧

  • 每当您设计一个新形状时,尽量将其抽象到一个单独的函数(例如 DrawCubeDrawSphere 等)中,而不是将运动直接嵌入到您的代码中。在未来的某个版本中,我可能会创建一个可以扩展的抽象 Shape 类。
  • 在操纵光标时很容易迷失方向。在整个开发周期中,我将一个玩具飞机放在旁边,并用它来跟踪光标的旋转。我鼓励您也弄一个玩具飞机,一部分是因为它可以帮助您可视化三维运动,另一部分是因为飞机实在是太棒了

    Toy Airplane

关于数学

这里面有很多数学知识。考虑到这个项目最初是作为一个尝试可视化我一直在研究的数学问题开始的,这很自然。大部分数学都与向量有关,如果您想深入研究代码,最好学习向量运算。我从《3D 数学入门:图形和游戏开发》这本书中学到了所有关于向量数学的知识,这是一本非常好的 3D 数学入门书籍。这本书为它提出的几乎所有数学都提供了几何解释,这确实有助于您直观地理解您正在做什么。我在代码的 XML 注释中引用了该书的特定页面。

关于单元测试

我在项目中包含了一套相对庞大的 NUnit 测试。请记住,浮点运算很复杂。有大约半兆兆种不同的边缘情况,如果您不注意,它们会绊倒您。除了要处理像 Infinity 和 NaN 这样的情况外,执行一个导致微小精度损失但会迅速累积成非常大的精度损失的操作相对容易。包含的测试检查了广泛输入的输出。总的来说,我已经处理了这些问题,所以如果您只是按原样使用该库,则不必太担心。但是,如果您打算扩展此库,最好相应地扩展单元测试。

参考文献

历史

  • 2006 年 6 月 7 日 - 首次发布。
© . All rights reserved.