初学者 - 使用 GDI+ 开始 2D 游戏开发






4.97/5 (16投票s)
关于使用 GDI+、渲染循环和多线程设置 2D 游戏的基础知识。以及一些技巧。
介绍
本主题讨论使用基本多线程和窗体创建基于渲染循环的游戏所需的绝对最基本知识。我们不打算创建实际的游戏。
我假设读者已经具备 VB.NET 的一些知识。希望 C# 和 .NET 开发者都能轻松理解。示例为 VB.NET。
背景
这受到 Google(搜索结果)的启发,偶然发现了游戏开发讨论以及那些为了创造游戏而专门学习编程的人们创建的简单游戏。
然而,您经常会发现的是:Windows 窗体使用 Picturebox 作为精灵或图形,面板,Windows 窗体按钮等等。
老实说,使用 PictureBox-on-Forms 方法开发游戏是开始构建游戏或游戏原型最丑陋的方式之一。
GDI 本身并不特别快,但通过正确的代码,它可以做惊人的事情。当您查看像 Quake2 中找到的软件光栅化器的源代码时,就会意识到这一点。
这也是我访问这个网站几年来发表的第一篇文章——得益于其他编码人员的解释和他们的源代码,许多我曾经认为困难的事情现在变得容易(或更轻松)了。现在,我想回报一下。我会尽力给出清晰的解释。
开始吧
我将从解释基于渲染循环的 GDI+ 游戏的基本需求开始。
- 线程:您的主渲染线程。独立于应用程序上下文。
- 后台缓冲区:您绘制的一切——都进入这里。
- 显示:您的一切都显示在这里。
这意味着需要以下内容:
- 位图:这是您的后台缓冲区
- 图形表面:您可以用它来绘制到您的后台缓冲区。
- 另一个图形表面:这是从您的显示窗口创建的,用于输出您的后台缓冲区。
所以,当我们将这些需求翻译成代码时,我们会得到以下内容。
' This is your BackBuffer, a Bitmap:
Dim B_BUFFER = new Bitmap(Me.Clientsize.Width,Me.Clientsize.Height)
' This is the surface that allows you to draw on your backbuffer bitmap.
Dim G_BUFFER = new Graphics( B_BUFFER ) 'drawing surface
' This is the surface you will use to draw your backbuffer to your display.
Dim G_TARGET = Me.CreateGraphics ' target surface
后台缓冲区(B_BUFFER
)是 System.Drawing.Bitmap
。它与 System.Drawing.Image
有一些相似之处。您可以使用 Bitmap 或 Image 作为后台缓冲区,并且两者都被 Graphic.New()
和 Graphics.FromImage()
接受。您只需要其中一个,而不是同时需要两个。
G_BUFFER
是一个允许您在 Bitmap 上绘图的对象,一个 System.Drawing.Graphics
。我将其命名为 G_BUFFER
,因为它充当了我 B_BUFFER
的图形设备。将其想象成画布。在这里使用的任何绘图例程都将把结果应用到 B_BUFFER
位图。
G_TARGET
(在上面的代码注释中)仅用于此目的。更新显示,也就是您将渲染到的窗体。在这种情况下。我们就是窗体。我们已向窗体请求了一个绘图表面,我们将用它来绘制 B_BUFFER
。
提示:您可以通过使用 Bitmap.Clone()
来模拟“分层渲染”。尝试找出如何做到。提示:您仍然只将更新后的位图绘制到显示屏一次。不要创建太多克隆(clone())。
常见问题:闪烁和渲染缓慢
GDI+ 绝非超快。但在其默认状态下,Graphics 对象并未针对最快的渲染进行设置。但在此文章之外,还有方法可以利用它做出惊人的事情。
我将解释两个简单快速的解决方案来解决闪烁和渲染缓慢的问题,它们不能创造奇迹:
- 禁用窗体的自动刷新
- 为 GDI 设置最佳性能
以窗体为例,简而言之——闪烁通常是由 Windows 告诉窗体清除自身引起的——这通常会覆盖窗体上所有立即模式的渲染,并用它喜欢的任何颜色的空白结果替换。当.NET Framework 的阴影中发生的其他事件试图替换您想保留在显示屏上的一切时,看起来并不好看。
解决方案:
Me.SetStyle(System.Windows.Forms.ControlStyles.AllPaintingInWmPaint, True) ' True is better
Me.SetStyle(System.Windows.Forms.ControlStyles.OptimizedDoubleBuffer, True) ' True is better
' Disable the on built PAINT event. We dont need it with a renderloop.
' The form will no longer refresh itself
' we will raise the paint event ourselves from our renderloop.
Me.SetStyle(System.Windows.Forms.ControlStyles.UserPaint, FALSE) ' False is better
前两个是自 explanatory 的,以下内容来自 Visual Studio 中的对象浏览器(快捷键:F2):
System.Windows.Forms.ControlStyles.AllPaintingInWmPaint:如果为 true,则控件会忽略窗口消息 WM_ERASEBKGND
以减少闪烁。
此样式仅应在 System.Windows.Forms.ControlStyles.UserPaint
位设置为 true 时应用。
System.Windows.Forms.ControlStyles.OptimizedDoubleBuffer
如果为 true,则控件首先绘制到缓冲区而不是直接绘制到屏幕,这可以减少闪烁。如果将此属性设置为 true,则还应将 System.Windows.Forms.ControlStyles.AllPaintingInWmPaint
设置为 true。
仅这两项就足以带来巨大改变。窗体不再会在您不希望它发生时自行清除。您绘制的所有内容都会出现在目标(G_TARGET
)表面上并保持持久。将窗体拖到桌面边缘将不再清除窗体的这些区域。如果您想将其清除为特定颜色(通常是个好主意)至少一次以清除窗口中的所有垃圾,那么您可以使用 Graphics.Clear()
。在这种情况下:G_TARGET.clear()
。
Me.SetStyle(System.Windows.Forms.ControlStyles.UserPaint, FALSE)
这会将窗体的 Paint
事件禁用,当设置为 False 时。这意味着 Form.Paint
事件不再自动引发。正如上面代码注释中所述,我们将自己调用我们的 paint 事件。
但是——如果您仔细阅读,您会看到:“此样式仅应在 System.Windows.Forms.ControlStyles.UserPaint
位设置为 true 时应用。”
然后当然是 UserPaint
的描述:
摘要:如果为 true,则控件自己绘制而不是由操作系统绘制。如果为 false,则不会引发 System.Windows.Forms.Control.Paint
事件。此样式仅适用于派生自 System.Windows.Forms.Control
的类。
现在再次查看上面的代码示例,然后仔细再次阅读
AllPaintingInWmPaint
的描述!您应该已经注意到了。
现在您应该在脑海中产生类似以下的问题:
问题:天啊,你为什么要把 Userpaint
设置为 false?
答案很简单:我想精确在我想要的时间引发我的 paint 事件。因为我自己在引发 paint 事件,所以我不希望任何其他 paint 事件侵占我应该控制的控件。我想消除使用 GDI 进行游戏或游戏原型开发时,除渲染循环本身之外的任何不必要的 paint 事件。
这些属性的描述假设您以某种方式希望为您引发 paint 事件,而我们的情况并非如此。此外,我们正在尽量消除我们称之为中间人的奇怪事物。
因此,当 AllPaintingInWmPaint
= true 并且 Userpaint
= false 时,没有人、没有外星人或其他 .NET 生物会告诉您的窗体引发 paint 事件,除了您自己的代码。
这尤其重要,因为我们将在循环的每个周期引发 paint 事件——额外的、不想要的 paint 事件可能不会很健康,甚至可能导致一些“颜料泼溅”,会变得一团糟!
接下来——您不能直接从您的窗体类中引发 Paint
事件,并且在您键入时 Visual Studio 会告诉您同样的信息。您需要用您自己的代码替换它,如以下示例所示:
Shadows Event Paint(ByVal G As Graphics)
在此示例中,我们将仅传递 G
——一个 Graphics
对象,它实际上是我们之前提到的 G_BUFFER
graphics。
在我们版本的 Paint
事件中,我已排除 PaintEventArgs
。上面的代码现在允许我们直接从 Form
实例中引发 Paint 事件。
或者,您也可以直接调用 OnPaint()
例程。我个人出于某种奇怪而不寻常的原因不喜欢这样做。番茄或土豆——对吧?
提到的另一个问题是渲染缓慢。以下内容将应用于配置您的 Graphics
对象以获得最佳渲染——但它们不能创造奇迹。
' Configure the display (target) graphics for the fastest rendering.
With G_TARGET
.CompositingMode = Drawing2D.CompositingMode.SourceCopy
.CompositingQuality = Drawing2D.CompositingQuality.AssumeLinear
.SmoothingMode = Drawing2D.SmoothingMode.None
.InterpolationMode = Drawing2D.InterpolationMode.NearestNeighbor
.TextRenderingHint = Drawing.Text.TextRenderingHint.SystemDefault
.PixelOffsetMode = Drawing2D.PixelOffsetMode.HighSpeed
End With
' Configure the backbuffer's drawing surface for optimal rendering with optional
' antialiasing for Text and Polygon Shapes
With G_BUFFER
'Antialiasing is a boolean that tells us weather to enable antialiasing.
'It is declared somewhere else
If Antialiasing then
.SmoothingMode = Drawing2D.SmoothingMode.AntiAlias
.TextRenderingHint = Drawing.Text.TextRenderingHint.AntiAlias
else
' No Text or Polygon smoothing is applied by default
endif
.CompositingMode = Drawing2D.CompositingMode.SourceOver
.CompositingQuality = Drawing2D.CompositingQuality.HighSpeed
.InterpolationMode = Drawing2D.InterpolationMode.Low
.PixelOffsetMode = Drawing2D.PixelOffsetMode.Half
End With
以 GDI+ 追求速度很重要。在大多数情况下,更高质量的设置几乎察觉不到。我已将 G_TARGET
配置为最佳设置。当更新目标时,我们将只使用一种例程,这是 GDI+ 中最快的绘制位图方式:DrawImageUnscaled()
。
对于 G_BUFFER
,配置是相同的,除了我们有一个选项可以对我们的 Backbuffer 位图进行文本、线条或多边形形状的抗锯齿处理。我还将 PixelOffsetMode
设置为一半。Visual Studio 中每个属性和值的描述都相当直观。
设置
我们通过以下方式控制我们的显示尺寸和抗锯齿开关:
' some vars containing properties:
Dim DisplaySize As New Size(800, 600)
Dim Antialiasing as Boolean = false;
提示:您可以使用 MY.Settings
存储值,并有一个带选项的启动屏幕。
' We want our drawing area to be the desired DisplaySize
' - form will(should) grow to accommodate client area.
Me.ClientSize = DisplaySize
' Lock windowsize
Me.FormBorderStyle = Windows.Forms.FormBorderStyle.FixedSingle
' Resize events are ignored:
Me.SetStyle(ControlStyles.FixedHeight, True)
Me.SetStyle(ControlStyles.FixedWidth, True)
在上面,我们将 Form.ClientSize
(而不是窗体大小)设置为所需的显示尺寸。如代码注释中所述。自然,窗体会调整大小,以便客户端区域满足我们的需求。
在此之后,我们通过将边框样式设置为 Windows.Forms.FormBorderStyle.FixedSingle
并使用 SetStyle
来禁用任何大小调整事件,从而锁定了窗体的大小调整能力。
如果您想启用大小调整,您还应该记住在重新初始化它们以适应新的客户端尺寸之前,先处理 B_BUFFER
和 G_BUFFER
,以确保它们先被卸载。G_TARGET
不需要重新初始化。
在 Sub New() 中
' configure form:
Me.SetStyle(System.Windows.Forms.ControlStyles.AllPaintingInWmPaint, True) ' True is better
Me.SetStyle(System.Windows.Forms.ControlStyles.OptimizedDoubleBuffer, True) ' True is better
' Disables the on built PAINT event. We dont need it with a renderloop.
' we will raise the paint event ourselves
Me.SetStyle(System.Windows.Forms.ControlStyles.UserPaint, False) ' False is better
' We want our drawing area to be the desired DisplaySizesize
' - form will grow to accommodate client area.
Me.ClientSize = DisplaySize
' Lock windowsize
Me.FormBorderStyle = Windows.Forms.FormBorderStyle.FixedSingle
' Resize events are ignored:
Me.SetStyle(ControlStyles.FixedHeight, True)
Me.SetStyle(ControlStyles.FixedWidth, True)
完整的 _renderloop
例程——包括初始化和处理 Bitmap 和两个 graphics 表面
' In this routine
' -- we configure the form, the buffer, drawing and target surface
' -- loop until form closes
' -- raise our paint event were the actual painting of game graphics will take place
' -- disposes our surfaces and buffer when we are done with them
' All in one function
' Does not allow dynamic resizing. That you can experiment with.
Private Sub _renderloop()
' Create a backwash - er Backbuffer and some surfaces:
Dim B_BUFFER = New Bitmap(Me.Clientsize.Width, Me.Clientsize.Height) ' backbuffer
Dim G_BUFFER = New Graphics(B_BUFFER) 'drawing surface
Dim G_TARGET = Me.CreateGraphics ' target surface
' Clear the random gibberish that would have been behind (and now imprinted in) the form away.
G_TARGET.Clear(Color.SlateGray)
' Configure Surfaces for optimal rendering:
With G_TARGET ' Display
.CompositingMode = Drawing2D.CompositingMode.SourceCopy
.CompositingQuality = Drawing2D.CompositingQuality.AssumeLinear
.SmoothingMode = Drawing2D.SmoothingMode.None
.InterpolationMode = Drawing2D.InterpolationMode.NearestNeighbor
.TextRenderingHint = Drawing.Text.TextRenderingHint.SystemDefault
.PixelOffsetMode = Drawing2D.PixelOffsetMode.HighSpeed
End With
With G_BUFFER ' Backbuffer
' Antialiased Polygons and Text?
If AntiAliasing Then
.SmoothingMode = Drawing2D.SmoothingMode.AntiAlias
.TextRenderingHint = Drawing.Text.TextRenderingHint.AntiAlias
Else
' defaults will not smooth
End If
.CompositingMode = Drawing2D.CompositingMode.SourceOver
.CompositingQuality = Drawing2D.CompositingQuality.HighSpeed
.InterpolationMode = Drawing2D.InterpolationMode.Low
.PixelOffsetMode = Drawing2D.PixelOffsetMode.Half
End With
' The Loop, terminates automatically when the window is closing.
While Me.Disposing = False And Me.IsDisposed = False And Me.Visible = True
' we use an exception handler because sometimes the form may be
' - beginning to unload after the above checks.
' - Most exceptions I get here are during the unloading stage.
' - Or attempting to draw to a window that has probably begun unloading already .
' - also any errors within OnPaint are ignored.
' - Use Exception handlers within OnPaint()
Try
' Raise the Paint Event - were the drawing code will go
RaiseEvent Paint(G_BUFFER)
' Update Window using the fastest available GDI function to do it with.
With G_TARGET
.DrawImageUnscaled(B_BUFFER, 0, 0)
End With
Catch E As exception
' Show me what happened in the debugger
' Note: Too Many exception handlers can cause JIT to slow down your renderloop.
' - One should be enough. Stack Trace (usually) tells all!
#If DEBUG then
debug.print(E.tostring)
#End If
End Try
End While
' If we are here then the window is closing or has closed.
' - Causing the loop to end
' Clean up:
G_TARGET.Dispose()
G_BUFFER.Dispose()
B_BUFFER.Dispose()
' Routine is done. K THX BYE
End Sub
不带注释的循环本身,以便查看引发的事件和目标更新,而没有注释的干扰
While Me.Disposing = False And Me.IsDisposed = False And Me.Visible = True
Try
RaiseEvent Paint(G_BUFFER)
With G_TARGET
.DrawImageUnscaled(B_BUFFER, 0, 0)
End With
Catch E As exception
#If DEBUG then
debug.print(E.tostring)
#End If
End Try
End While
根据我的经验,我通常只会在窗体开始卸载后偶尔遇到一个异常。这并不总是发生。只是捕获一个线程试图在错误的时间做某事——它最终会发生。
以上,非常简单——每个周期都执行以下操作:
- 检查是否正在卸载。如果是——退出循环!
- 引发 paint 事件。
- 捕获任何异常并将它们发送到调试器。
此外——不要尝试对控件进行不安全的跨线程调用。当您这样做时,您会知道——一个异常会告诉您您正在这样做。但无论如何,尽量将与 WinForms 控件相关的事务保留在渲染循环线程之外。但如果您真的必须这样做:您可以在这里阅读,以及这里。此网站上也有很多示例。
最后——重要的是:
' Clean up:
G_TARGET.Dispose()
G_BUFFER.Dispose()
B_BUFFER.Dispose()
让一个对象卸载纯粹是可选的。但是,为什么让别人清理您留在内存中的东西呢?
仅仅因为垃圾收集器先生专门负责收集应用程序人行道上的垃圾和杂物,并不意味着他喜欢它。有时他甚至会碍手碍脚。
最好回收那些将被重用的对象,首先将它们丢弃。如果您不打算再次使用它们——就丢弃它们。此外,在游戏中,您不希望 GC 在渲染循环运行时频繁启动,这会影响性能(即使对于小型应用程序来说也很小)。清理所有您不再需要的东西。
我认为这仅仅是我的个人观点,但我见过 XNA 开发者抱怨并寻找方法来摆脱垃圾收集器。
在源代码中
提供的源代码,运行时应如下所示:
源代码中还有比此处描述的更多的代码。在 VS2008 中构建。
兴趣点
- 通过一些巧妙的编程技巧,可以实现完全 3D 的软件光栅化器。请参阅 Quake 2 中的软件光栅化器(它甚至有一个 C# 移植版本!)。代码中的注释解释了所有内容。请参阅 C++ 版本:Quake 2 源代码审查。但是,在 .NET 上,仅使用托管 API 可能会太慢。此外,您可以通过编译为原生(使用 NGEN)来摆脱 JIT,以消灭名为 CIL 的中间人。(以后再学习)。
Aspiring .NET 游戏引擎开发者的技巧和挑战
以下是您可以最终尝试实现的:
- 一次渲染过多位图会减慢速度。跟踪哪些在显示区域内,哪些不在。
- 我发现使用线程一次创建太多位图有时会比内存分配速度更快。导致过早的“内存不足异常”。——使用单个资源列表(每个级别?)来一次性加载所有图像资源。所有精灵/纹理都应从此加载。加载可以使用 BackgroundWorker 进行。
- 学习如何为您的 Windows GUI 创建一个 Marshal Thread。
- 学习如何在 .NET4 中进行并行处理。
- 通过直接调用 GDI32.DLL 来 Blit。您可以通过这种方式消灭更多中间人。没有法律禁止消灭中间人——将它们视为花园侏儒。您最终想把它们踢翻...
- 不要试图将每个 GDI 例程调用都放入自己的微线程中。这不会更快。GDI 会用一个由虫洞制造的冰淇淋蛋筒攻击您,目的是在您死后冒充开膛手杰克。相信我——我试过一次。我什么都试过至少一次。..
- 我做的事情:通过创建自己的 Bitmap 类并直接访问内存中的像素数组来替换 GDI Bitmap 的使用。这比使用包装的 API 方法能更快地操作像素数据。尝试做到最优。
- 所有非交互式图形最好渲染到一个资源(在加载屏幕期间),例如:一幅带有不可交互树木的地图——渲染到地图——使用一张地图。仅采样可见区域——在采样区域之上渲染更多动态内容。
- 跟踪移动的对象。树形比较——除非对象在动画,否则不要更新或重绘不移动的对象。仅重新渲染移动对象以及“接触”到这些对象的区域。
- 在您自己的游戏引擎中直接创建自己的数学库。直接计算复杂数学比调用 API 调用/包装器(花园侏儒)快一点。如果许可证允许,您还可以将其他项目的数学源代码合并到您的项目中——遵循他们的规则或选择与您的发布许可证匹配的代码。
- 实现一个脚本引擎。(避免使用包装器——中间 API 库。)
- 使用非矩形命中区域、缓存边界框、对象规避(防碰撞)进行对象碰撞。
- 最终切换到 XNA,或 DirectX/Direct2D。DirectX 和 Direct2D 的包装器可用 SlimDX 和 SharpDX。这两个 API 在很大程度上是相同的。或者您可以尝试使用 OpenGL。
- 循序渐进。做到最优。
历史
- 2012年6月25日:原始版本。