他们是如何做到的:Terraria中的数百万块瓷砖


45

我一直在开发类似于Terraria的游戏引擎,这主要是一个挑战,尽管我已经弄清了其中的大部分内容,但我似乎并没有真正为它们如何处理数百万个可交互/可收割的图块而费心游戏一次完成。在我的引擎中,创建约500.000个图块,这是Terraria可能生成的图块的1/20,导致帧速率从60下降到约20,即使我仍然只在视图中渲染图块。请注意,我没有对磁贴做任何事情,只是将其保留在内存中。

更新:添加代码以显示我的操作方式。

这是类的一部分,它处理并绘制图块。我猜想罪魁祸首是“ foreach”部分,该部分会迭代所有内容,甚至是空索引。

...
    public void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        foreach (Tile tile in this.Tiles)
        {
            if (tile != null)
            {
                if (tile.Position.X < -this.Offset.X + 32)
                    continue;
                if (tile.Position.X > -this.Offset.X + 1024 - 48)
                    continue;
                if (tile.Position.Y < -this.Offset.Y + 32)
                    continue;
                if (tile.Position.Y > -this.Offset.Y + 768 - 48)
                    continue;
                tile.Draw(spriteBatch, gameTime);
            }
        }
    }
...

这里也是Tile.Draw方法,它也可以进行更新,因为每个Tile使用对SpriteBatch.Draw方法的四个调用。这是我的自动平铺系统的一部分,这意味着根据相邻的图块绘制每个角。texture_ *是矩形,在创建关卡时设置一次,而不是在每次更新时设置。

...
    public virtual void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        if (this.type == TileType.TileSet)
        {
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position, texture_tl, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(8, 0), texture_tr, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(0, 8), texture_bl, this.BlendColor);
            spriteBatch.Draw(this.texture, this.realm.Offset + this.Position + new Vector2(8, 8), texture_br, this.BlendColor);
        }
    }
...

欢迎对我的代码提出任何批评或建议。

更新:解决方案已添加。

这是最终的Level.Draw方法。Level.TileAt方法只是检查输入的值,以避免OutOfRange异常。

...
    public void Draw(SpriteBatch spriteBatch, GameTime gameTime)
    {
        Int32 startx = (Int32)Math.Floor((-this.Offset.X - 32) / 16);
        Int32 endx = (Int32)Math.Ceiling((-this.Offset.X + 1024 + 32) / 16);
        Int32 starty = (Int32)Math.Floor((-this.Offset.Y - 32) / 16);
        Int32 endy = (Int32)Math.Ceiling((-this.Offset.Y + 768 + 32) / 16);

        for (Int32 x = startx; x < endx; x += 1)
        {
            for (Int32 y = starty; y < endy; y += 1)
            {
                Tile tile = this.TileAt(x, y);
                if (tile != null)
                    tile.Draw(spriteBatch, gameTime);

            }
        }
    }
...

2
您是否绝对肯定,您只是在渲染摄像机视图中的内容,这意味着确定要渲染的内容的代码正确吗?
库珀

5
帧速率从60 fps下降到20 fps,仅仅是因为分配了内存吗?那是极不可能的,肯定有什么问题。它是什么平台?系统是否将虚拟内存交换到磁盘?
Maik Semder 2011年

6
@Drackir在这种情况下,我什至说即使有一个tile类,也应该做一个合适的长度整数数组,这是错误的,而且当有50万个对象时,OO开销也不是在开玩笑。我猜想可以处理对象,但是重点是什么呢?整数数组很容易处理。
aaaaaaaaaaaaaa

3
哎哟! 迭代所有图块,在视图中的每个图块上调用Draw四次。肯定有一些改进,可能这里....
thedaian

11
您不需要下面讨论的所有奇特分区仅呈现视图中的内容。这是一张地图。它已经被划分为常规网格。只需计算屏幕左上角和右下角的图块,然后在该矩形中绘制所有内容即可。
Blecki 2011年

Answers:


56

渲染时是否要遍历所有500,000个图块?如果是这样,那很可能会造成部分问题。如果渲染时循环遍历一百万个图块,而对它们执行“更新”勾时则遍历五十万个图块,那么每帧将遍历一百万个图块。

显然,有解决方法。您可以在渲染的同时执行更新更新,从而将遍历所有这些图块的时间节省一半。但这将您的呈现代码和更新代码绑定到一个函数中,通常是BAD IDEA

您可以跟踪屏幕上的图块,并仅遍历(并渲染)这些图块。根据瓦片大小和屏幕大小等因素,这可以轻松减少您需要遍历的瓦片数量,从而节省大量处理时间。

最后,也许最好的选择(大多数大型世界游戏都这样做)是将地形分成多个区域。将世界划分为512x512的块,并在玩家靠近或远离某个区域时加载/卸载区域。这也使您不必遍历遥远的图块即可执行任何类型的“更新”刻度。

(很明显,如果您的引擎未在磁贴上执行任何类型的更新刻度,则可以忽略此答案中提到这些的部分。)


5
如果我没记错的话,该区域部分似乎很有趣,这就是Minecraft的工作方式,但是,即使您距离它不远,它也不会与Terraria的地形如何变化(例如草蔓延,仙人掌生长等)一起起作用。 编辑:当然,除非它们应用从该区域上次活动以来的经过时间,然后执行该时间段内将发生的所有更改。这可能实际上起作用。
William Mariager

2
Minecraft肯定使用区域(后来的Ultima游戏也使用了区域,我很确定贝塞斯达的许多游戏都使用了区域)。我不太确定Terraria如何处理地形,但是它们可能将经过的时间应用于“新”区域,或者将整个地图存储在内存中。后者会要求较高的RAM使用率,但是大多数现代计算机都可以处理。可能还有其他未提及的选项。
thedaian 2011年

2
@MindWorX:在重新加载时进行所有更新并非总是可行的-假设您有一堆贫瘠的地方,然后放草。你走了好久,然后走向草地。当带有草的块加载时,它会赶上并在该块中传播,但不会传播到已经加载的较近的块中。这样的事情进展MUCH慢于比赛,虽然-只检查瓷砖的一小部分在任何一个更新周期,并可以使远处的地形生活在没有装载该系统。
罗伦·佩希特尔

1
作为在某个区域靠近玩家时“追赶”的一种替代方法(或补充),您可以单独进行一次模拟,遍历每个遥远的区域并以较慢的速度更新它们。您可以为每个帧保留时间,也可以在单独的线程上运行它。因此,例如,您可能会更新播放器所在的区域以及每帧6个相邻区域,而且还要更新1个或2个额外区域。
Lewis Wakeford 2013年

1
对于任何想知道的人,Terraria都将其全部图块数据存储在2d数组中(最大大小为8400x2400)。然后使用一些简单的数学方法确定要渲染的图块部分。
FrenchyNZ 2014年

4

我在这里看到一个巨大的错误,没有任何答案可以解决。当然,您永远不应该绘制和迭代更多的图块,而您也需要。不太明显的是您如何实际定义图块。如我所见,您创建了一个tile类,我也经常这样做,但这是一个巨大的错误。您可能在该类中具有各种功能,并且创建了许多不必要的处理。

您只应迭代处理真正需要的内容。因此,请考虑您实际需要的瓷砖。要绘制'm,您只需要一个纹理,但是您不想在实际图像上进行迭代,因为要处理的图像很大。您可以只创建一个int [,]甚至是一个无符号字节[,](如果您不希望有更多的255个图块纹理)。您需要做的就是遍历这些小数组,并使用switch或if语句绘制纹理。

那么,您需要更新什么?类型,健康状况和损害似乎足够。所有这些都可以以字节存储。那么为什么不为更新循环创建这样的结构:

struct TileUpdate
{
public byte health;
public byte type;
public byte damage;
}

您实际上可以使用该类型来绘制图块。因此,您可以从结构中分离该结构(使其自己组成一个数组),以免重复绘制循环中不必要的运行状况和损坏字段。为了进行更新,您应该考虑比屏幕更宽的区域,以便游戏世界更加活跃(实体在屏幕外更改位置),但是对于绘制事物,您只需要可见的图块即可。

如果保留上述结构,则每个图块仅占用3个字节。因此,出于保存和存储目的,这是理想的。对于处理速度,如果使用int或byte,或者如果使用64位系统,则使用long int并不重要。


1
是的,我的引擎后来的迭代演变为使用它。主要是为了减少内存消耗并提高速度。与由ByVal结构填充的数组相比,ByRef类型的速度较慢。尽管如此,您最好将其添加到答案中。
William Mariager 2013年

1
是的,在寻找一些随机算法时偶然发现了它。立即注意到您在代码中使用自己的tile类的位置。新产品时,这是一个非常常见的错误。很高兴看到您自2年前发布以来仍然活跃。
Madmenyo

3
您不需要healthdamage。您可以为最近挑选的瓷砖位置以及每个瓷砖上的损坏保留一小块缓冲区。如果在处选择了一个新的图块,并且缓冲区已满,则在添加新的图块之前将其从最旧的位置滚动下来。这限制了您一次可以挖掘多少个图块,但是无论如何(大约#players * pick_tile_size)都有一个固有的限制。如果可以简化此列表,则可以按玩家保留此列表。大小的确事速度; 较小的大小意味着每个CPU高速缓存行中的切片数更多。
肖恩·米德迪奇

@SeanMiddleditch你是对的,但这不是重点。关键是停下来思考一下您实际需要在屏幕上绘制图块并进行计算的情况。由于OP使用的是整个课堂,所以我举了一个简单的例子,简单对学习进度有很大帮助。现在请打开我的像素密度主题,您显然是程序员,试图用CG艺术家的眼光看待这个问题。由于该网站也适用于游戏afaik的CG。
Madmenyo 2014年

2

您可以使用不同的编码技术。

RLE:因此,您从坐标(x,y)开始,然后计算沿着其中一个轴并排(长度)存在的相同图块的数量。示例:(1,1,10,5)表示从坐标1,1开始,有10个并排的图块类型5。

大规模数组(位图):数组的每个元素都保留在该区域中的图块类型。

编辑:我只是在这里遇到了一个很好的问题: 地图生成的随机种子函数?

Perlin噪声发生器看起来像是一个很好的答案。


我真的不确定您是在回答所提出的问题,因为您正在谈论编码(和Per​​lin噪声),而且该问题似乎涉及性能问题。
thedaian 2011年

1
然后,使用适当的编码(例如perlin噪声),您不必担心立即将所有这些图块保存在内存中。而是只需让编码器(噪声生成器)告诉您应该在给定坐标处显示的内容即可。在CPU周期和内存使用之间保持平衡,而噪声生成器可以帮助实现这种平衡。
山姆·艾克斯

2
这里的问题是,如果您要制作一款允许用户以某种方式修改地形的游戏,则仅使用噪声生成器“存储”该信息将不起作用。另外,与大多数编码技术一样,噪声的产生也相当昂贵。最好将CPU周期用于实际的游戏玩法(物理,碰撞,照明等)。Perlin噪声对于生成地形非常有用,而编码对于保存到磁盘也很有利,但是在这种情况下,有更好的处理内存的方法。
thedaian 2011年

但是,非常好的主意,例如thedaian,地形是由用户与之交互的,这意味着我无法数学上检索信息。无论如何,我都会看一下Perlin噪声,以生成基本地形。
William Mariager

1

您可能应该已经按照建议对tilemap进行分区。例如,使用四叉树结构来摆脱不必要(非可见)图块的任何潜在处理(例如,甚至简单地循环通过)。这样,您仅处理可能需要处理的内容并增加数据集(平铺图)的大小不会造成任何实际的性能损失。当然,假设树平衡良好。

我不想通过重复“旧的”来听起来平淡无奇,但是在进行优化时,请始终记住使用工具链/编译器支持的优化,您应该尝试一下。是的,过早的优化是万恶之源。相信您的编译器,在大多数情况下,它比您知道的多,但始终,永远两次测量,永远不要依靠猜测。只要您不知道实际的瓶颈在哪里,就不必快速执行最快的算法。这就是为什么您应该使用探查器来查找代码中最慢的(热)路径,并专注于消除(或优化)它们的原因。对目标体系结构的低级了解通常对于挤出硬件必须提供的所有内容至关重要,因此请研究这些CPU缓存并了解分支预测器是什么。查看您的探查器告诉您有关缓存/分支命中/未命中的内容。正如使用某种形式的树数据结构所显示的那样,拥有智能数据结构和愚蠢算法要比采用其他方法更好。在性能方面,数据是第一位的。:)


+1代表“过早的优化是万恶之源”我对此感到内::(
thedaian 2011年

16
-1四叉树?有点矫kill过正?当在这样的2D游戏中所有图块的大小相同时(它们用于Terraria),非常容易找出屏幕上的图块范围-它只是屏幕左上角(在屏幕上)最右下角的图块。
BlueRaja-Danny Pflughoeft 2011年

1

难道不是所有抽奖电话太多了吗?如果将所有地图的贴图纹理放入单个图像-贴图集,则渲染时将不会切换纹理。而且,如果将所有图块批处理为一个网格,则应在一次绘制调用中绘制它。

关于动态调整...也许四叉树并不是一个坏主意。假设将瓦片放置在叶子中,并且非叶子节点只是其子对象的成批网格,则根应包含所有成一个网格的瓦片。删除一个图块需要节点更新(网格划分)直到根。但是在每个树级别上,只有1/4网格划分的网格不应该那么多,4 * tree_height网格连接吗?

哦,如果您在裁剪算法中使用这棵树,您将不总是渲染根节点,而是渲染其某些子节点,因此,您甚至不必更新/重新分批将所有节点更新为根节点,而无需更新/重新分批复制您的节点(非叶节点)目前渲染。

只是我的想法,没有代码,也许是胡说八道。


0

@到达是正确的。问题是抽奖代码。您正在构建每帧4 * 3000 +个绘制四边形命令(24000+个绘制多边形命令)的数组。然后,这些命令将被处理并通过管道传输到GPU。这很糟糕。

有一些解决方案。

  • 将大块瓷砖(例如,屏幕尺寸)渲染为静态纹理,并在一次调用中将其绘制。
  • 使用着色器绘制图块时,无需每帧将几何图形发送到GPU(即将图块图存储在GPU上)。

0

您需要做的是将世界划分成多个区域。Perlin噪声地形生成可以使用通用种子,因此即使世界尚未预先生成,种子也将构成噪声算法的一部分,从而将较新的地形很好地缝合到现有部分中。这样,您无需一次在玩家视图之前计算出更多的小缓冲区(当前屏幕周围只有几个屏幕)。

在处理诸如植物生长在远离播放器当前屏幕的区域中之类的事情时,您可以使用计时器。这些计时器将遍历存储有关植物及其位置等信息的文件。您只需要读取/更新/保存计时器中的文件。当玩家再次到达世界的那些地方时,引擎将正常读取文件,并在屏幕上显示较新的植物数据。

我在去年制作的类似游戏中使用了这项技术,用于收割和耕种。玩家可以离开田野,返回时,物品已更新。


-2

我一直在思考如何处理这么多的块,而唯一想到的就是Flyweight设计模式。如果您不知道,我强烈建议您阅读以下内容:在节省内存和处理方面可能会有所帮助:http : //en.wikipedia.org/wiki/Flyweight_pattern


3
-1这个答案太笼统而无用,并且您提供的链接不能直接解决此特定问题。Flyweight模式是有效管理大型数据集的许多方法之一。您能否详细说明如何在类似Terraria的游戏中存储图块的特定上下文中应用此模式?
postgoodism 2012年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.