C# 和 WPF 实现 3D L-System
如何使用 C# 创建 WPF 3D 图形。
引言
我想学习一些使用 WPF 和 C# 进行 3D 图形编程的知识,并认为实现一个简单的 3D 应用程序来绘制 L-系统分形会是一个有趣的方式。
背景
L-系统基础
L-系统(Lindenmayer 系统)是一种字符重写系统,最初是为了算法描述植物发育而开发的。使用一些简单的规则,你可以描述许多分形模式,以及与植物非常相似的模式。
一个典型的 L-系统有一个公理(起始字符串)和用于扩展字符串的规则。此程序实现了以下规则:
[ push ] pop F Draw forward f move forward B draw backwards b move backwards + rotate +z pitch - rotate -z pitch } rotate +y yaw { rotate -y yaw > rotate +x roll < rotate -X roll
对于 2D 系统,只实现了 + 和 – 旋转。对于 3D 系统,你还需要实现另外两个维度的旋转。你还需要定义旋转的距离和线条长度。
通常使用占位符字符或重新定义命令字符以简化规则设置会很有用。此应用程序还支持命令重定义。
简单的 2D 示例
公理:A
规则:A=BA, B=AB
迭代
- A
- BA
- ABBA
- BAABABBA
现在,这个例子什么也做不了,但是如果规则和公理由上面规则中的字母组成,那么你最终会得到一个长字符串,其中包含绘制形状的命令。例如:
公理:F
规则:F=F+F--F+F
角度:60
迭代次数:2
创建此图片
可以使用 push 和 pop 命令来完成分支模式
公理:F
角度:35
规则:F=FF-[-F+F+F]+[+F-F-F]
3D 示例
下一个示例是 3D 希尔伯特立方体(希尔伯特正方形的 3D 版本)。
公理:X
角度:90
规则:X=+<XF+<XFX{F+>>XFX-F}>>XFX{F>X{>
请注意,X 不是功能性字母。公理和规则可以使用非功能性字母作为占位符,这使得更复杂的模式更容易作为 L-系统实现。
使用代码
大多数 3D WPF 代码示例都有大量的 XAML 源代码。XAML 是实现简单 3D 场景的简单方法,但我想使用 C# 代码实现我的应用程序。此演示应用程序使用 XAML 设置允许输入规则和其他参数的表单,但所有 3D 编码都是使用 C# 调用完成的。
3D WPF 场景的最低要求
要在 WPF 下绘制 3D 场景,你至少需要以下内容:
- 一个用于绘制场景的控件(我使用网格)
- 一个 ViewPort
- 一个 3D 模型
- 一个相机
- 一盏灯
网格名为 GridDraw。创建相机和灯光并将其添加到视口。创建一个简单的 3D 模型并将其添加到视口,以及一个变换,以便模型可以移动和旋转。以下代码是在 WPF 中显示 3D 场景的最低要求。
viewport3D = new Viewport3D();
modelVisual3DLight = new ModelVisual3D();
baseModel = new ModelVisual3D();
model3DGroup = new Model3DGroup();
transformGroup = new Transform3DGroup(); // for rotating the model via the mouse
camera = new PerspectiveCamera();
camera.Position = new Point3D(0, 0, -32);
camera.LookDirection = new Vector3D(0, 0, 1);
camera.FieldOfView = 60;
camera.NearPlaneDistance = 0.125;
light = new DirectionalLight();
light.Color = Colors.White;
light.Direction = new Vector3D(1, 0, 1);
modelVisual3DLight.Content = light;
// draw a 3D box from currLocation to nextLocation, color c
geometryBaseBox = Create3DLine(currLocation, nextLocation, c, lineThickness, boxUp);
model3DGroup.Children.Add(geometryBaseBox);
// the above two lines of code is repeated for each box that makes up the current
// pattern. More on that later
baseModel.Transform = transformGroup;
baseModel.Content = model3DGroup
viewport3D.Camera = camera;
viewport3D.Children.Add(modelVisual3DLight);
viewport3D.Children.Add(baseModel);
GridDraw.Children.Add(viewport3D);
此时,WPF 将在网格控件上绘制我们的盒子。
创建命令字符串
当然,其中一个 L-系统图像将包含大量组成模型的盒子,这些盒子在循环中添加到 Model3DGroup 中,因为我们读取了通过处理公理和规则创建的命令字符串。虽然绘制命令字符串需要一个递归函数来支持 push/pop 命令,但命令字符串的创建是纯粹迭代的。
处理公理和规则的代码非常简单
- 将公理添加到字符串中
- 对于每次迭代
- 对于每个规则
- 扫描字符串,如果字符与规则匹配,则将字符替换为规则字符串。
- 如果当前字符没有规则,则直接复制它
- 对于每个规则
- 替换任何重新定义的命令
- 对于每次迭代
绘制命令字符串
一旦定义了公理和规则,我们就得到了一个长字符串,它是绘制图像的指令。现在我们所要做的就是逐个字符地处理这个字符串,遵循指令。在 2D 系统中,这非常简单,你可以直接使用简单的三角函数来绘制图案。
例如,如果最终字符串是 F+F+F,我们将
- 从当前位置沿当前方向向前绘制一条线。
- 旋转当前方向
- 沿新方向绘制一条线
- 旋转
- 绘制最后一条指向新方向的线
在 3D 中旋转需要跟踪我们当前方向的横摇、俯仰和偏航。尝试使用三个独立的角度和三角函数来执行此操作会导致许多问题,包括万向节锁。因此,我们使用四元数来跟踪当前角度。每次我们需要旋转时,我们只需将当前方向乘以一个四元数,该四元数的轴沿我们想要旋转的轴,其角度设置为当前旋转量。WPF 四元数结构支持这些方法,但它缺少一个方法来为你提供一个指向四元数当前指向的方向的向量,该向量可用于知道下一个位置在哪里,即沿四元数指向的方向移动。在 XNA 中,这只是 Q.Forward。一个使用 WPF 矩阵结构的简单函数解决了这个问题
// make a vector point in the direction of the quaternion with magnitude dd
private Vector3D QuatToVect(Quaternion q, double dd, Vector3D f)
{
Matrix3D m = Matrix3D.Identity;
m.Rotate(q);
f=m.Transform(f * dd);
return f;
}
现在我们拥有处理完整命令字符串所需的所有部分。这是从命令字符串创建 3D 模型的代码摘录。这里只包含 F + 和 – 命令。下载中包含完整代码。
变量
- str:命令字符串
- max:字符串长度
- currLocation 一个 Vector3D
- nextLocation 一个 Vector3D
- boxUp 一个 Vector3D,指向当前盒子的“上方”
- quatRot 一个四元数,表示我们当前正在绘制的方向
- vPitch 一个四元数,其轴沿 Z 轴
- vForward 我们如何向前移动
Vector3D vPitch = new Vector3D(0, 0, 1);
Color[] someColors = { Colors.Red, Colors.Blue, Colors.Green };
Vector3D vMove = new Vector3D();
Vector3D vForward = new Vector3D(1, 0, 0);
for (i = index; i < max; i++)
{
c=someColors[i%someColors.Length]; //pick one of the colours
switch (str[i])
{
case 'F': // draw forward
vMove = QuatToVect(quatRot, lineLength, vForward);
nextLocation = currLocation + vMove;
geometryBaseBox = Create3DLine(currLocation, nextLocation, c,
lineThickness, boxUp);
model3DGroup.Children.Add(geometryBaseBox);
currLocation = nextLocation;
break;
case '+': // Pitch
quatRot *= new Quaternion(vPitch, rotAngle);
break;
case '-': // Pitch
quatRot *= new Quaternion(vPitch, -rotAngle);
break;
}
}
添加所有盒子后,我们将整个组添加到我们的模型中
// add the model created by DrawStringLow baseModel.Transform = transformGroup; baseModel.Content = model3DGroup;
以上代码只是“绘制”命令字符串的完整函数中的一个摘录。此函数被递归调用以处理分支结构所需的 push/pop 命令。从高层次看,完整代码执行以下操作:
- 获取用户参数
- 创建命令字符串
- 设置 3D WPF 环境
- 将 3D 盒子递归添加到模型中,每个盒子都是模型中的 3D“线条”。
转换模型
接下来,我们希望能够从各种角度和位置查看模型。一种方法是围绕模型移动相机。这是一种非常好的方法,因为它不需要向模型添加变换,但我们只使用此方法进行放大和缩小。有许多 WPF 示例用于在以模型为中心的逻辑球体上移动相机。这是缩放代码:
// zoom camera in and out, shift key zooms faster
private void MouseWheel_GridDraw(object sender, MouseWheelEventArgs e)
{
double speed = 100.0;
if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
speed /= 8.0;
camera.Position = new Point3D(
camera.Position.X,
camera.Position.Y,
camera.Position.Z - e.Delta / speed);
}
所有这些都只是改变了相机的 Z 位置。
要旋转和平移模型,我们获取用户的鼠标输入并添加旋转或平移变换。这个简单的代码只是随着用户移动鼠标不断添加更多的变换。这只是一个演示如何变换模型。我不会在“真实”应用程序中使用此方法。对于“真实”应用程序,你需要通过计算新的整体变换矩阵并只应用该一个变换来最小化应用于模型的变换数量。
请注意,transformGroup 是添加到 modelGroup 的所有小盒子的整个捆绑包的 transformGroup。
// translate or rotate our model
// some people just move the camera, but this is a demo
private void GridDraw_MouseMove(object sender, MouseEventArgs e)
{
if (currState == state.rotate)
{
double dx, dy;
dx = mX - e.GetPosition(GridDraw).X;
dy = mY - e.GetPosition(GridDraw).Y;
RotateTransform3D rotateT;
if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
{
dx *= 4.0;
dy *= 4.0;
}
if (Math.Abs(dx) > smallD)
{
// rotate on the Z axis
rotateT = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(0, 0, 1), dx));
transformGroup.Children.Add(rotateT); // this is the transform for the model so this // rotates it.
}
if (Math.Abs(dy) > smallD)
{
// rotate on the Z axis
rotateT = new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(1, 0, 0), dy));
transformGroup.Children.Add(rotateT); // this is the transform for the model so this // rotates it.
}
mX = e.GetPosition(GridDraw).X;
mY = e.GetPosition(GridDraw).Y;
}
else
{
if (currState == state.translate)
{
double ts;
double dx, dy;
ts = translateSensitivity;
if ( Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift))
ts /= 4.0;
dx = (mX - e.GetPosition(GridDraw).X)/ts;
dy = (mY - e.GetPosition(GridDraw).Y)/ts;
TranslateTransform3D translateT;
if (Math.Abs(dx) > smallD)
{
// move in x direction
translateT = new TranslateTransform3D(dx, 0, 0);
transformGroup.Children.Add(translateT);
}
if (Math.Abs(dy) > smallD)
{
// move in y direction
translateT = new TranslateTransform3D(0, dy, 0);
transformGroup.Children.Add(translateT);
}
mX = e.GetPosition(GridDraw).X;
mY = e.GetPosition(GridDraw).Y;
}
}
}
保存和加载
最后,我们希望能够保存和加载这些模式的参数。XML 是现在保存此类内容的方式(我想我只是暴露了我的年龄),所以这里是保存和加载代码的摘录。加载代码将整个 xml 文件加载到内存中,然后将数据放回正确的文本框中。保存代码将每个参数保存在自己的 xml 节点中,并将所有规则保存在一个集合中。
// lets save the parms in a trendy xml file
private void ButtonSave_Click(object sender, sw.RoutedEventArgs e)
{
// save this pattern in a simple xml file
XmlDocument xml = new XmlDocument();
XmlNode root;
XmlNode node;
root = xml.CreateElement("pattern");
xml.AppendChild(root);
node = xml.CreateElement("axiom");
node.InnerText = TextboxAxiom.Text;
root.AppendChild(node);
// do the same for the others parms here…
// all the rules
string[] sep = { "\r\n" };
string[] lines = TextboxRules.Text.Split(sep, StringSplitOptions.RemoveEmptyEntries);
foreach (string str in lines)
{
node = xml.CreateElement("rule");
node.InnerText = str;
root.AppendChild(node);
}
Microsoft.Win32.SaveFileDialog diag = new Microsoft.Win32.SaveFileDialog();
diag.Filter = "xml files (*.xml)|*.xml|text files (*.txt)|*.txt|all files(*.*)|*.*";
diag.FilterIndex = 0;
Nullable<bool> result=diag.ShowDialog();
if ( result == true )
{
xml.Save(diag.FileName);
}
}
// lets read in all the parms from a trendy xml file
private void ButtonLoad_Click(object sender, sw.RoutedEventArgs e)
{
Microsoft.Win32.OpenFileDialog diag = new Microsoft.Win32.OpenFileDialog();
diag.Filter = "xml files (*.xml)|*.xml|text files (*.txt)|*.txt|all files(*.*)|*.*";
diag.FilterIndex = 0;
Nullable<bool> result=diag.ShowDialog();
if ( result == true)
{
try
{
XmlNode node;
XmlNodeList nodeList;
XmlDocument xml = new XmlDocument();
xml.Load(diag.FileName);
node = xml.SelectSingleNode("/pattern");
if (node == null)
{
sw.MessageBox.Show("Root node: pattern not found. Likely not a pattern file.");
}
else
{
node = xml.SelectSingleNode("pattern/axiom");
if (node != null)
{
TextboxAxiom.Text = node.InnerText;
}
node = xml.SelectSingleNode("pattern/initialAngle");
if (node != null)
{
TextboxInitialAngle.Text = node.InnerText;
}
// do the same for the rest of the parms here…
TextboxRules.Clear();
foreach (XmlNode n in nodeList)
{
TextboxRules.AppendText(n.InnerText + "\r\n");
}
// above loop adds a blank rule to the end, makestring has to ignore this extra // blank line
}
}
catch (Exception ex)
{
sw.MessageBox.Show("Error: " + ex.Message);
}
}
}
最终评论
当你下载代码并查看它时,你可能会想知道绘制 3D 盒子时“向上”向量是什么。给定两个 3D 点,在绘制盒子时,哪个方向是“向上”并不明显。你可以很容易地获得一个垂直于这两个点的平面,但这对于定向盒子没有多大帮助。向上向量跟踪哪个方向是向上。它最初硬编码为向上,然后每次方向旋转时都会旋转它,以使其与绘图保持同步。
这部分基于我的2D 文章。
历史
第一版