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

控制台的重塑

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.82/5 (26投票s)

2009 年 3 月 26 日

CPOL

9分钟阅读

viewsIcon

80357

downloadIcon

822

一个用 C# 和 DirectX 编写的多视图控制台。

2.jpg

目录

引言

可敬的控制台(也称为终端)已经陪伴我们很长时间了,而且永远不会消失——这就是为什么对于每种语言,Visual Studio 都提供创建控制台项目的选项。然而,控制台本身是一个遗物——它速度慢、外观丑陋,更不用说缺乏灵活性且无法扩展。本文描述了我尝试制作一个更好的控制台。

要编译本文中的示例,您需要 Visual Studio 2008 和 DirectX SDK。要运行它,您需要确保您的 DirectX 版本支持 MDX 扩展。如果不支持,请下载最新版本。

问题陈述

在功能需求方面,我希望我的控制台具有以下特性:

  • 速度——当前的控制台慢得离谱,有时我需要快速向控制台写入大量数据,因此快速渲染至关重要。
  • 多源——Windows 控制台是线程安全的,这很好,但它仍然将所有内容写入一个列表。我希望能够从不同的源写入控制台的不同位置(例如,在多列布局中)。
  • 自定义和基本排版——控制台不是桌面出版系统,但我仍然希望拥有选择字体和颜色的基本选项。

以下是实现特性:

  • 线程安全——想象一下,如果原始控制台不是线程安全的,生活会变得多么糟糕。我希望保留这一特性。
  • 硬件加速——由于速度如此重要,我希望有图形硬件加速。不允许使用 GDI。
  • 灵活性——我希望有一种架构能够轻松创建和组合控制台元素。

技术

在选择编码语言时,我有两个要求。一个是硬件加速,这意味着我必须选择一个允许与显卡进行低级交互并允许我渲染漂亮的 2D 图像的 API。在当今世界,这是一个相当二元的选择(即 DirectX 与 OpenGL)。

另一个要求是它易于在 .NET 中使用——可能通过简单地在某个地方注入一个 using 语句来取代典型的 Console.Write/Line 调用。

这两个要求的交集导致我选择了 C# 作为编程语言(为什么要拒绝自己呢?)和 Managed DirectX 作为编程 API。现在,对于大多数人来说,Managed DirectX 是一种彻底“死亡”的技术,但我的选择在这种方式上受到了一些限制——我不想使用 XNA,而且我对 SlimDX1 的实验甚至没有让我通过设备创建阶段。

因此,我选择了所谓的 MDX2(代表 Managed DirectX)。MDX 本质上是一种为 DirectX API 提供 .NET 包装器的技术。它不再受微软支持,但它能正常工作,并且是 DirectX 运行时分发的一部分。事后看来,我可以说我没有遇到任何问题,这可能是因为我没有将其用于任何高级功能。

架构

我对架构的看法基于这样一个事实:我厌倦了 Windows 控制台只有一个缓冲区和一个写入文本的位置。我想要一些更灵活的东西,例如,允许我同时显示 10 种不同类型的分析的实时输出,并将它们输出到同一个控制台的不同部分。因此,我提出了三个概念——缓冲区视口控制台

1.jpg

缓冲区

控制台缓冲区只是存储文本的一些内存,对吗?所以理论上,编码会非常简单——只需创建一个 char[,] 就可以了。然而,实际上存在几个问题。

我希望控制台有自动换行功能,以使文本输出更整洁。因此,我实现了代码来检查提供的文本是否适合一行,如果不适合,则尝试拆分单词。当然,也有一些边界情况,例如一行只包含空格(例如,当用户想用空格作为填充时)。在这种情况下,我保留原样。

缓冲区的另一个问题是溢出,即行数用完时。我没有在旧行被新行推出时重新定位多行,而是提出了一个插入点的想法,以保留缓冲区的起始行。然后,当发生溢出时,新行会覆盖最旧的行,我们只需在缓冲区中循环一次,从插入点开始和结束。

视口

正如眼睛是心灵的窗户一样,视口是缓冲区的一个窗口——它可以显示缓冲区的一部分(从任何合法的 x-y 缓冲区位置开始),也可以显示所有内容。给定一个缓冲区,它提供一个索引器属性来获取视口坐标中的字符。

public char this[int x, int y]
{
  get
  {
    return Buffer[x + bufferLocation.X, 
      (Buffer.StartLine + y + bufferLocation.Y) % Buffer.Size.Height];
  }
  protected internal set
  {
    Buffer[x + bufferLocation.X, 
      (Buffer.StartLine + y + bufferLocation.Y) % Buffer.Size.Height] = value;
  }
}

当然,我们的控制台不直接使用视口——还有一个间接层,其中字符被转换为纹理。让我们看看这是如何实现的。

纹理管理器

TextureManager 类根据指定的参数为每个字母3创建纹理。这些参数包括字体、前景色和背景色。这些参数的每种组合都构成一个预设。预设存储在以下字段中:

private readonly List<Texture[]> presets = new List<Texture[]>();

如您所见,我们实际上有一个 intTexture 的映射。预设是使用 AddPreset() 方法创建的,该方法使用 GDI 将字符渲染到 DirectX 纹理上。

public short AddPreset(Font font, Color bgColor, Color fgColor)
{
  // create textures for each individual character
  var textures = new Texture[charCount];
  using (var bmp = new Bitmap(charSize.Width, charSize.Height, 
                              PixelFormat.Format32bppArgb))
  using (Graphics g = Graphics.FromImage(bmp))
  using (Brush brush = new SolidBrush(fgColor))
    for (int i = 0; i < CharCount; ++i)
    {
      g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
      g.Clear(bgColor);
      g.DrawString(string.Format("{0}", (char) i), font, brush, 0.0f, 0.0f);
      Texture t = Texture.FromBitmap(device, bmp, 0, Pool.Managed);
      Debug.Assert(t != null);
      textures[i] = t;
    }
  presets.Add(textures);
  return (short) (presets.Count - 1);
}

由于用户不太可能频繁切换预设,我们将预设保留在一个属性中,而不是将其作为索引器参数。因此,要从纹理管理器获取纹理,我们可以使用以下索引器:

public Texture this[int letter]
{
  get { return presets[CurrentPreset][letter]; }
}

您会注意到 letter 参数是一个 int——这是因为数组索引器通常是 int 类型。当然,要使用纹理管理器,您将传入一个 char,例如 var myTex = myTexMgr['a'];

控制台

我们现在要深入到控制台架构的“核心”——Console 类本身!这个类主要创建控制台窗口,并驱动各个视口的渲染。我不会详细介绍创建 MDX 窗口的整个过程,而是会展示一些有趣的特性以及核心渲染机制。

我遇到的第一个问题是设备创建。如何创建一个与最终用户硬件兼容的 Direct3D 设备?我的做法是尝试几种选项:最快优先,最慢最后。

// here are a couple of pairs to try
var pairsToTry = new[]
{
  new Pair<DeviceType, CreateFlags>(DeviceType.Hardware, 
                       CreateFlags.HardwareVertexProcessing),
  new Pair<DeviceType, CreateFlags>(DeviceType.Hardware, 
                       CreateFlags.SoftwareVertexProcessing),
  new Pair<DeviceType, CreateFlags>(DeviceType.Software, 
                       CreateFlags.SoftwareVertexProcessing),
  new Pair<DeviceType, CreateFlags>(DeviceType.Reference, 
                       CreateFlags.SoftwareVertexProcessing),
};
for (int i = 0; i < pairsToTry.Length; i++)
{
  Pair<DeviceType, CreateFlags> p = pairsToTry[i];
  try
  {
    device = new Device(0, p.First, this, p.Second, pp);
    break;
  }
  catch
  {
    continue;
  }
}
if (device == null)
  throw new ApplicationException("Could not create device.");

这种方法允许我在必要时回退到软件模式,但我很快意识到,即使在最快的机器上,软件模式也慢得几乎无法使用。我想这限制了此控制台的可用性,仅限于具有硬件加速的机器。

创建设备后,我尝试设置设备状态,以便我的字母不会被缩放,即,10×14 的纹理将在 10×14 的矩形上渲染而不会失真。

private void ResetDeviceStates()
{
  device.RenderState.CullMode = Cull.None;
  device.RenderState.Lighting = false;
  device.RenderState.ZBufferEnable = false;
  device.SetSamplerState(0, SamplerStageStates.MinFilter, 0);
  device.SetSamplerState(0, SamplerStageStates.MagFilter, 0);
}

然而,这并没有完全奏效——我渲染的纹理不知为何仍然显得“不对劲”。实际上,它们在垂直和水平方向上都偏移了恰好 0.5 像素,因为我忘记补偿像素-纹素不匹配4。因此,代码必须修改为将每个纹理坐标精确移动 0.5 像素。

private void OnCreateVertexBuffer(object sender, EventArgs e)
{
  // co-ordinates are explicitly shifted by half a pixel each way
  // to compensate for pixel/texel mismatch
  var v = (CustomVertex.PositionTextured[]) vb.Lock(0, 0);
  v[0].X = 0.0f + 0.5f;
  v[0].Y = 0.0f + 0.5f;
  v[0].Z = 0.0f;
  v[0].Tu = 0.0f;
  v[0].Tv = 1.0f;
  ⋮
}

我费了很大力气才让矩阵按应有的方式呈现,即正确地将 2D 平面映射到 3D。最后,我使用了以下(事后看来有些明显)代码来正确投影我的控制台。

private void SetupMatrices()
{
  device.Transform.World = Matrix.Identity;
  device.Transform.View = Matrix.LookAtLH(
    new Vector3(0.0f, 3.0f*distanceToObject, -5.0f*distanceToObject),
    new Vector3(0.0f, 0.0f, 0.0f),
    new Vector3(0.0f, 1.0f, 0.0f));
  device.Transform.Projection = Matrix.OrthoRH(
    ClientSize.Width, ClientSize.Height, -100.0f, 100.0f);
}

当实际渲染控制台时,我实现了以下算法:

  • 遍历控制台的每个 X 和 Y 坐标。
  • 遍历每个缓冲区;如果缓冲区需要在此位置渲染内容,则:
  • 设置纹理并绘制它。
  • 每个字符绘制后,将视图矩阵按字符宽度平移。
  • 每行字符绘制后,将视图矩阵向下平移一行并移到控制台的左侧。

我不会在这里展示代码;但是,我想展示设置要渲染纹理的行。

device.SetTexture(0, TexManager[v[x - v.ScreenLocation.X, y - v.ScreenLocation.Y]]);

在这里,您可以看到纹理管理器和视口(变量 v)之间的相互作用。本质上,我们从视口获取正确的字符,然后将其传递给纹理管理器,纹理管理器会生成相应的 Texture 对象。

用法

控制台最简单的用法如下:

using (Console c = Console.NewConsole(30, 20)) {
  c.Show();
  // your code here
  while (c.Created) {
    c.Render();
    Application.DoEvents();
  }
}

源代码中提供了更复杂的示例。

已知错误

在编写控制台时,我拼命寻找关于如何处理设备重置的权威信息。然而,网上的每个资源都只提供了零散的、通常无用的信息,所以我必须承认我未能编写正确处理这些情况的代码5。因此,如果您在窗口模式下运行控制台并调整窗口大小,字形将变得模糊不清。截至撰写本文时,我不知道如何解决这个问题。

结论

我这里展示的只是用控制台可以做什么的冰山一角。由于它由 DirectX 驱动,您可以在控制台的 API 中添加大量的动画和附加功能,而不会严重消耗 CPU。然而,这里展示的功能足以满足我将多个长时间运行的分析源重定向到单个控制台的需求。

参考文献

  1. ^SlimDX 是另一个托管 DirectX 框架。更多信息请参见 http://code.google.com/p/slimdx/
  2. ^它仍然被称为 MDX,但微软的 OLAP 查询语言(例如,参见 维基百科条目)也叫做 MDX,这可能会引起一些混淆。
  3. ^你可能已经猜到,它无法高效处理扩展字符集(例如日语),因为需要创建的纹理太多了。这是一个设计决策——我只对拉丁字符感兴趣,所以没有问题。
  4. ^这种不匹配是由于纹理坐标确实指向它们的位置,所以如果你将一个纹素(纹理像素)放在 (0, 0),你实际上是将它的中心放在这个位置,而不是将它的左上角放在那里。
  5. ^但我怀疑大多数 MDX 开发者在这方面也失败了。
© . All rights reserved.