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

Unity3D 等距插件

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.62/5 (4投票s)

2019年1月16日

CPOL

13分钟阅读

viewsIcon

7479

这是一个关于如何为Unity Asset Store编写插件的故事,尝试解决游戏中众所周知的等距问题,并从中赚取一些咖啡钱,同时了解Unity编辑器的可扩展性。内含图片、代码、图表和想法。

序言

那是在一个晚上,我发现自己闲得没事做。来年我的职业生涯并不太有前景(不像个人生活,但那是另一回事)。无论如何,我有一个想法,想重温旧梦,写点有趣的东西,一些非常个人的东西,属于我自己,但仍带有一点商业优势(我只是喜欢我的项目能让别人感兴趣,除了我的雇主,那种温暖的感觉)。这一切都与我早已想看看Unity编辑器扩展的可能性,并想知道它的平台在销售引擎的扩展方面是否有好的前景的事实不谋而合。

我花了一天时间研究Asset Store:模型、脚本、与各种服务的集成。起初,似乎一切都已经被编写和集成,甚至还有许多不同质量和细节级别的选项,价格和支持也一样。因此,我立即将范围缩小到

  • 仅限代码(毕竟,我是一名程序员)
  • 仅限2D(因为我就是喜欢2D,而且Unity刚刚为它提供了不错的开箱即用支持)

然后,我回想起之前制作等距游戏时,我吃了多少“仙人掌”(比喻遇到的困难),又有多少“鼠标” died。你不会相信我们花了多少时间寻找可行的解决方案,又在试图理清等距和绘制它时浪费了多少精力。所以,强忍着内心的冲动,我尝试用各种关键词和非关键词搜索,一无所获,除了大量的等距艺术,直到我最终决定从头开始制作一个等距插件。

设定目标

我需要简要描述的第一件事是,这个插件要解决什么问题,以及等距游戏开发者会如何利用它。因此,等距问题如下:

  • 按远近对对象进行排序,以便正确绘制它们
  • 用于在编辑器中创建、定位和移动等距对象的扩展

因此,随着第一个版本的主要目标已经明确,我给自己设定了2-3天的时间来完成第一个草稿版本。你看,这不能推迟,因为热情是易碎的,如果你在最初几天没有完成一些东西,很有可能你会把它搞砸。而且,即使在俄罗斯,新年假期也没有想象中那么长,我希望在十天左右发布第一个版本。

排序

简而言之,等距就是2D精灵试图看起来像3D模型的尝试。这当然会导致许多问题。最主要的问题是,精灵必须按绘制顺序进行排序,以避免相互重叠的问题。

在截图上,你可以看到绿色精灵(2,1)先被绘制,然后是蓝色精灵(1,1)

截图显示了当蓝色精灵先被绘制时的错误排序

在这种简单的情况下,排序不会是太大问题,并且会有一些选项,例如:

  • 按屏幕上的Y位置排序,即 `(isoX + isoY) * 0.5 + isoZ`
  • 从最远的等距网格单元从左到右、从上到下绘制 `[(3,3),(2,3),(3,2),(1,3),(2,2),(3,1),...]`
  • 以及其他各种有趣或不那么有趣的绘制方式

它们都很好,速度快且有效,但仅限于这种单单元对象或沿 `isoZ` 方向延伸的列:)毕竟,我对此感兴趣的是更通用的解决方案,它可以适用于沿一个坐标方向延伸的对象,甚至是没有宽度但沿必要高度方向延伸的“围栏”。

截图显示了沿3x1和1x3方向延伸的对象的正确排序方法,以及测量为3x0和0x3的“围栏”

问题就出在这里,让我们不得不决定前进的方向:

  • 将“多单元”对象分解为“单单元”对象,即垂直切割,然后对出现的条带进行排序
  • 思考一种新的、更复杂、更有趣的排序方法

我选择了第二种方案,因为我不想深入研究对每个对象的复杂处理,甚至不想自动切割,也不想特殊处理逻辑。顺便说一句,像 **Fallout 1** 和 **Fallout 2** 这样著名的游戏就采用了第一种方法。如果你查看这些游戏的内部数据,你实际上可以看到这些条带。

所以,第二种方案不包含任何排序标准。这意味着没有预先计算的排序值。如果你不相信我(我猜很多没接触过等距的人都不信),拿一张纸画出像 **2x8** 和 **2x2** 这样的小对象。如果你设法找到一个计算其深度和排序的值,那么就添加一个 **8x2** 的对象,并在它们相互相对的不同位置上尝试排序。

所以,没有这样的值,但我们仍然可以使用它们之间的依赖关系(粗略地说,哪个对象覆盖哪个对象)来进行拓扑排序。我们可以通过将等距坐标投影到等距轴上来计算对象的依赖关系。

截图显示蓝色立方体依赖于红色立方体

截图显示绿色立方体依赖于蓝色立方体

用于确定两个轴(Z轴也一样)依赖关系的伪代码

bool IsIsoObjectsDepends(IsoObject obj_a, IsoObject obj_b) {
  var obj_a_max_size = obj_a.position + obj_a.size;
  return
    obj_b.position.x < obj_a_max_size.x &&
    obj_b.position.y < obj_a_max_size.y;
}

通过这种方法,我们构建所有对象之间的依赖关系,递归地遍历它们并标记显示Z坐标。这种方法相当通用,最重要的是,它有效。例如,你可以在这里这里阅读这种算法的详细描述。流行的Flash等距库(as3isolib)也使用了这种方法。

这一切都很棒,只是这种方法的时空复杂度是 **O(N^2)**,因为我们必须将每个对象与其他所有对象进行比较才能建立依赖关系。我把优化留给了以后版本,只添加了延迟重排序,这样在某些东西移动之前就不会进行排序。所以我们稍后会谈到优化。

编辑器扩展

从现在开始,我有了以下目标:

  • 对象排序必须在编辑器中工作(不只是在游戏中)
  • 还需要另一种Gizmos-Arrow(用于移动对象的箭头)
  • 可选地,在移动对象时将其与图块对齐
  • 图块的大小将自动应用并设置在等距世界检查器中
  • 根据其等距大小绘制AABB对象
  • 在对象检查器中输出等距坐标,通过更改这些坐标,我们可以更改对象在游戏世界中的位置

所有这些目标都已实现。Unity确实允许大大扩展其编辑器。你可以添加新的选项卡、窗口、按钮,对象检查器中新的字段。如果你愿意,你甚至可以为特定类型的组件创建一个自定义检查器。你也可以在编辑器窗口中输出额外的信息(在我的例子中,是关于AABB对象),并且替换对象的标准移动Gizmos。编辑器内部的排序问题通过这个神奇的ExecuteInEditMode标签来解决,它允许在编辑器模式下运行对象的组件,也就是像在游戏中一样。

当然,所有这些的实现并非没有困难和各种技巧,但没有一个问题我花费了超过几个小时(Google、论坛和社区肯定帮助我解决了所有未在文档中提及的问题)。

截图显示了我用于在等距世界中移动对象的Gizmos

Release

所以,我的第一个版本准备好了,拍了截图。我甚至画了一个图标,写了一份说明。时候到了。我设定了一个象征性的价格5美元,上传了插件到商店,并等待Unity批准。我没有过多考虑价格,因为我当时并不想赚大钱。我的目标是找出是否有普遍的需求,如果有,我想估计一下。此外,我想帮助那些在这种情况下几乎没有机会和附加功能的等距游戏开发者。

在5个相当痛苦的日子里(我花在编写第一个版本上的时间差不多,但我知道我在做什么,没有进一步的疑问和过度思考,这比刚开始接触等距的人速度更快),我收到了Unity的回复,说插件已获批准,并且我已经可以在商店里看到了,同样还有它的零(到目前为止)销量。我在本地论坛上查看了,在商店的插件页面集成了Google Analytics,并为等待时间做好了准备。

很快就有了第一批销量,以及论坛和商店的反馈。在1月份剩下的日子里,我的插件卖出了12份,我认为这是公众兴趣的标志,并决定继续下去。

优化

所以,我对两件事不满意:

  • 排序的时空复杂度 - O(N^2)
  • 垃圾回收问题和整体性能

算法

拥有100个对象和O(N^2)的复杂度,我需要进行10,000次迭代才能找到依赖关系,并且我还要遍历所有对象并标记显示Z以进行排序。应该有解决方案。所以,我尝试了无数种选择,整夜都在思考这个问题。无论如何,我不会告诉你我尝试过的所有方法,但我会描述我目前认为最好的方法。

首先,我们只对可见对象进行排序。这意味着我们需要时刻了解屏幕上的内容。如果有新对象,我们需要将其添加到排序过程中,如果旧对象消失了,就忽略它。现在,Unity不允许在场景树中确定包含其子对象的对象的边界框。遍历子对象(每次都如此,因为它们可以添加和移除)不可行——太慢了。我们也无法使用OnBecameVisible等事件,因为这些仅适用于父对象。但是我们可以获取必要对象及其子对象的Renderer组件。当然,这听起来不是最好的选择,但我找不到其他同样通用且性能可接受的方法。

List<Renderer> _tmpRenderers = new List<Renderer>();

bool IsIsoObjectVisible(IsoObject iso_object) {
  iso_object.GetComponentsInChildren<Renderer>(_tmpRenderers);
  for ( var i = 0; i < _tmpRenderers.Count; ++i ) {
    if ( _tmpRenderers[i].isVisible ) {
      return true;
    }
  }
  return false;
}
有一个小技巧是使用 `GetComponentsInChildren` 函数,它允许在必要的缓冲区中获取组件而不进行分配,不像另一个返回新组件数组的函数。

其次,我仍然需要解决 O(N^2) 的问题。我尝试了许多空间分割技术,最后停留在显示空间中一个简单的二维网格,我将我的等距对象投影到其中。每个这样的扇区都包含一个与其交叉的等距对象列表。所以,这个想法很简单:如果对象的投影不交叉,那么就没有必要在对象之间建立依赖关系。然后我们遍历所有可见对象,只在必要时构建依赖关系,从而降低算法的时间复杂度并提高性能。我们将每个扇区的大小计算为所有对象大小的平均值。我发现结果相当令人满意。

整体性能

当然,我可以就此写一篇单独的文章……好吧,让我们尽量简短。首先,我们缓存组件(我们使用 `GetComponent` 来查找它们,这并不快)。我建议大家在处理任何与 `Update` 相关的东西时都要注意。你必须时刻记住,它每帧都会发生,所以你必须非常小心。另外,记住所有有趣的功能,如自定义==运算符。有很多事情需要牢记,但最终,你会在内置的性能分析器中了解到它们。这使得记忆和使用它们更容易。:)

此外,你还会真正体会到垃圾回收器的痛苦。需要更高的性能?那么忘记任何可能分配内存的东西,在 **C#**(尤其是在旧的 **Mono** 编译器中)中,从 `foreach`(!)到出现的 lambda 表达式,都可以做到这一点,更不用说 **LINQ** 了,它即使在最简单的情况下也已被禁止使用。最终,你得到的不是带有语法糖的 **C#**,而是类似于 **C** 的粗糙版本,其功能非常有限。

在这里,我将提供一些关于这个主题可能对你有帮助的链接:第一部分第二部分第三部分

结果

我从未见过有人使用这种优化技术,所以我对看到结果感到特别高兴。而在第一版中,仅仅50个移动对象就能让游戏变成幻灯片,而现在即使有800个对象在画面中也能很好地工作:一切都在全速运转,并且只用3-6毫秒就能重新排序,这对于等距游戏中的这个数量的对象来说非常好。此外,初始化后,它几乎不会为一帧分配内存了:)

未来的可能性

在阅读了反馈和建议之后,我在过去的版本中添加了一些功能。

2D/3D混合

在等距游戏中混合2D和3D是一个有趣的可能性,可以最大限度地减少绘制不同移动和旋转选项(例如,动画角色的3D模型)。这并不是什么难事,但需要集成到排序系统中。你只需要获取模型的**边界框**及其所有子对象,然后沿显示Z轴移动模型,其宽度与边界框宽度相同。

Bounds IsoObject3DBounds(IsoObject iso_object) {
  var bounds = new Bounds();
  iso_object.GetComponentsInChildren<Renderer>(_tmpRenderers);
  if ( _tmpRenderers.Count > 0 ) {
    bounds = _tmpRenderers[0].bounds;
    for ( var i = 1; i < _tmpRenderers.Count; ++i ) {
      bounds.Encapsulate(_tmpRenderers[i].bounds);
    }
  }
  return bounds;
}
这是获取模型及其所有子对象的**边界框**的一个例子。

这是完成后的样子

自定义等距设置

这相对简单。我被要求实现设置等距角度、宽高比、图块高度的可能性。在忍受了一些数学上的痛苦之后,你会得到类似这样的结果。

物理

这里变得更有趣了。由于等距模拟的是3D世界,物理也应该是三维的,包括高度等。我提出了一个精彩的技巧。我复制了物理的所有组件,如`Rigidbody`、`Collider`等,用于等距世界。根据这些描述和设置,我使用引擎本身和内置的**PhysX**创建了一个看不见的3D物理世界的副本。之后,我获取计算出的模拟数据,并将它们传回给等距世界的复制组件。然后,我用同样的方法来模拟碰撞和触发事件。

工具集的物理演示GIF

尾声和结论

在我实现了论坛上的所有建议之后,我决定将价格提高到40美元,这样它就不会看起来只是另一个只有五行代码的廉价插件。:)我非常乐意回答问题并听取您的建议。我欢迎各种批评,谢谢!现在,我为最后留着的东西,这个月的销售统计数据:

5$ 40$
一月 12 0
二月 22 0
三月 17 0
四月 9 0
五月 9 0
六月 9 0
七月 7 4
八月 0 4
九月 0 5
© . All rights reserved.