Unity 可重复随机数
出处
https://blogs.unity3d.com/cn/2015/01/07/a-primer-on-repeatable-random-numbers/ (英文原版)
http://www.manew.com/thread-37144-1-1.html
不管创建什么样的程序,几乎都离不开随机数.如果您想多次生成同样的结果,这就需要随机数是可重复的。
在本片文章中我们将介绍使用关卡或世界的生成作为示例,但其中的原理也适用于许多其它内容,例如程序纹理、模型、音乐等等。然而,这并不适用于一些具有严格要求的应用程序,比如加密。
为什么想要多次产生同样的结果呢?
- 为了能够再次访问同样的关卡或世界。例如:通过一个特定的种子来创建一个确定的level/world。如果重复使用相同的种子,就可以重复创建相同的level/world。比如“我的世界”Minecraft就是用此原理。
- 为了动态生成持久的世界。如果要随着玩家四处移动来动态生成世界,您可能希望玩家再次访问时的坐标与原始状态保持一致(就像《Minecraft(我的世界)》、《No Man's Sky(无人深空)》等游戏中一样),而不是毫无逻辑每次都不同。
- 所有玩家都是同一个世界。可能您希望游戏中的世界对所有玩家来说都是一样的,就好像它不是程序生成的。《No Man's Sky(无人深空)》中 就有这样的例子 。这与上述提到的重复访问相同的关卡或场景基本相同,不同的是重复访问始终使用同一个种子。
我们多次提到了“种子”这个词。种子可以是数值、文本字符串或其它数据类型,它用来作为输入参数从而得到一个随机的输出结果。种子的特点就是相同的种子总是产生相同的输出结果,而一点细微的变化就能导致结果千差万别。
在本文中,我们将细述两种生成随机数的方法——随机数生成器和随机哈希(Hash)函数,以及选择使用它们的原因。据我所知这些内容并不常见,而且其它地方也没有类似资源,所以我在这里写下来与大家分享。
随机数生成器
最常见的生成随机数的方法就是通过随机数生成器(简称RNG)。许多编程语言都包含RNG类或函数,并且名字中带有“random”,所以显而易见这是开始使用随机数的首选方法。
随机数生成器按照初始种子来生成一组随机数。在面向对象语言中,随机数生成器通常是一个使用种子初始化的对象。然后重复调用该对象中的某个方法来生成随机数。
![](http://img.manew.com/data/attachment/forum/201501/28/094749w7z7gs3gblgn0glx.jpeg.thumb.jpg)
在C#中生成随机数的代码如下:
1
2
3
4
5
6
|
Random randomSequence = new Random ( 12345 ) ; int randomNumber 1 = randomSequence.Next ( ) ; int randomNumber 2 = randomSequence.Next ( ) ; int randomNumber 3 = randomSequence.Next ( ) ; |
这种情况下我们会得到一个0~2147483647(int类型最大值)之间的随机整数,我们也可以指定随机整数的范围,或者指定生成0~1之间的浮点数等等都是不费吹灰之力的。实现该功能的常见方法见下文。
下图是C#中的Random类经种子0初始化后首次生成的65535个随机数。每个随机数都以一个像素来表示,亮度在0(黑)与1(白)之间。
![](http://img.manew.com/data/attachment/forum/201501/28/094749d7xdjfiilrxx7la5.jpeg.thumb.jpg)
这里很重要的一点要理解,在没有获取第一个和第二个随机数之前是无法获取第三个随机数的。这不仅仅是它实现机制的一种表现。本质上,RNG生成的每个随机数都用作下一次生成计算的一部分。下面我们来说说随机序列。
![](http://img.manew.com/data/attachment/forum/201501/28/094749nsx9x11retrbzr22.jpeg.thumb.jpg)
这意味着,如果您想要的是一串逐个生成的随机数,RNG完全可以满足您,但是如果想获取某个特定的随机数(比如说,随机数序列中的第26个数),那就歇菜了。不过呢,您还是可以调用Next()函数26次然后取最后一次的结果,当然这只是说笑。
为什么要获取序列中某个特定的随机数呢?
如果同时生成所有内容,您可能不需要获取序列中某个特定的随机数,至少我认为没有必要。然而,如果是一点点动态生成,那就有必要了。
例如,假设您的世界中有三个区域:A、B和C。玩家一开始在A区,所以使用100个随机数来生成区域A。然后玩家继续使用另外100个不同的数来生成区域B。与此同时之前生成的A区域会被销毁并释放内存。同样还是使用另外100个随机数来生成C并释放B。
然而,如果玩家现在要回到区域B,那应该按照首次生成的100个随机数来生成区域B,从而使得该区域与原来一致。
可使用随机数生成器指定不同的种子来实现?
答案是不行!这是一个关于RNG非常常见的误解。事实上,事实上,尽管同一序列中的不同数字间相关性是完全随机的,但不同序列中相同索引的数字间却是相关的
![](http://img.manew.com/data/attachment/forum/201501/28/094749mnwr299klr5rrrr0.jpeg.thumb.jpg)
所以如果从100个序列中分别取出第一个数,它们之间并不是随机的,第10个、第100个或是第1000个也同样相干。
关于这点有人会表示怀疑,没有关系。您可以看看 Stack Overflow上关于RNG生成内容的讨论,如果您觉得更可靠。为了让本文更有趣且实用,我们还是来做些实验看看结果。
我们以相同序列中生成的随机数作为参考,然后与在种子取0~65535所生成的65536个序列中,分别取各序列的第一个数得到的序列数进行比较。
![](http://img.manew.com/data/attachment/forum/201501/28/094749fcefh9f13fuwwhf3.jpeg.thumb.jpg)
尽管图像更像是均匀分布,但它并不是随机的。实际上,我已经通过一个纯线性函数来比较并展示了输出,显而易见的是,使用种子序列生成的随机数并没有比直接使用线性函数更好。
这样就够随机了吗?够好了吗?
关于这点,通过更好的方法来衡量随机性是个不错的选择,因为肉眼并不是太可靠。为何?难道这个结果看起来还不够随机吗?
没错,我们最终的目标是让结果充分随机。但是根据使用方式不同生成的随机数结果也各不相同。您的生成算法可能会以各种各样的方式来生成随机值,将最终的值放在一个简单的序列中查看时就会发现其中隐藏的模式。
另外一种查看随机输出值的方法就是创建2D坐标系,并将随机数成对绘制在坐标系中最终生成图像。位于像素点的随机值越多,则该点的亮度越高。
下面我们来看看,同一序列中的随机数分布以及不同序列中各取一个的随机数分布的坐标图。还附上了线性函数图来比较。
![](http://img.manew.com/data/attachment/forum/201501/28/094750snuo85nl7mz7nfd6.jpeg.thumb.jpg)
您可能感到惊讶,使用不同种子生成的不同序列分别取一个值创建的坐标图,这些坐标都被描绘在细线上而不是任何接近均匀的分布。正如上述说的,与线性函数非常类似。
如果您要用随机数来创建坐标,用来在地形上布置树木。现在您所有的树木都被布置在一条直线上而留出了大量空地。
我们可以得出结论,就是随机数生成器只在您不需要以特定顺序来访问随机值时有用。如果您需要,那您可能想仔细了解下面的随机哈希函数。
随机哈希函数
I一般说来,哈希函数可以是任意函数,只要它可以用来将任意范围的数据映射为固定范围,并且输入参数一点微小的改变就能导致输出结果千差万别。
对于程序生成,典型的用例就是提供一个或多个整型数据作为输入,然后得到一个随机数作为输出。例如,比较大的世界可以一次只生成一部分,典型的需求就是得到一个与输入向量相关的随机数(例如世界中的坐标),如果输入相同则该随机数保持不变。与随机数生成器(RNG)不同,它是没有顺序的——您可以以任意您喜欢的顺序来获取随机数。
![](http://img.manew.com/data/attachment/forum/201501/28/094750qmrq6mq5mfrgvs6q.jpeg.thumb.jpg)
在C#中的示例代码如下(注意您可以按任意顺序来获取随机数):
1
2
3
4
5
6
|
RandomHash randomHashObject = new RandomHash ( 12345 ) ; int randomNumber 2 = randomHashObject.GetHash ( 2 ) ; int randomNumber 3 = randomHashObject.GetHash ( 3 ) ; int randomNumber 1 = randomHashObject.GetHash ( 1 ) ; |
The hash function may also take multiple inputs, which mean you can get a random number for a given 2D or 3D coordinate:
哈希函数也可以接收多个输入,也就是说您可以按照给定的2D或3D坐标来获取随机数:
1
2
3
4
5
6
7
8
|
RandomHash randomHashObject = new RandomHash ( 12345 ) ; randomNumberGrid[ 20 , 40 ] = randomHashObject.GetHash ( 20 , 40 ) ; randomNumberGrid[ 21 , 40 ] = randomHashObject.GetHash ( 21 , 40 ) ; randomNumberGrid[ 20 , 41 ] = randomHashObject.GetHash ( 20 , 41 ) ; randomNumberGrid[ 21 , 41 ] = randomHashObject.GetHash ( 21 , 41 ) ; |
程序生成随机数并非哈希函数的典型用法,也并不是所有的哈希函数都适用于程序生成随机数,因为它们可能不会充分随机分布,又或者性能开销过大。
哈希函数的应用之一就是作为数据结构实现的一部分,例如哈希表和字典。这些通常高效但不会充分随机,因为它们不是为随机而生而只是使算法更高效。理论上这种方式应该也是随机的,但实际上,我还没找到比较它们随机性的资源,而我测试的结果证明其随机性非常差(详情请看附录C)。
哈希函数另一个应用就是加密。这通常是非常随机的,但效率很低,因为加密需要的随机并非只是看上去的随机。
我们使用程序生成的目标就是创建一个随机并且高效的哈希函数,也就是说其效率不应低于本来的水平。编程语言中并未内置合适的函数供选择,而您又需要找到一个用于您的项目,这就是机会。
我已经按照网上的推荐和大量相关知识测试过几个不同的哈希函数。我从中选择了如下三者来进行比较。
- PcgHash: 我在Google Groups的论坛上 关于程序内容生成的讨论中看到了Adam Smith提供的这个函数。他提供了一些技能建议,自己创建随机哈希函数不难,他还提供了自己的代码片段PcgHash作为示例。
- MD5:这可能是大家最熟知的哈希函数。它同样用于加密也比我们的目标开销更大(它同样是密码级的算法强度,对我们的目标来说,算是杀鸡用牛刀了)。首先,我们通常只需返回一个32位的整型值,但MD5返回的是更大的哈希值,大多情况下我们会丢弃多余的位数。不过还是要拿它来作比较。
- xxHash:这是一个高性能非加密形式的哈希函数,正好满足我们随机性好且性能好的需求。
- 除了生成噪声点序列的图片和坐标图之外,我还利用随机性测试网站
ENT –伪随机数序列测试程序测试过。我在图像中包含了选择ENT的统计数据,还有一个我自己想的叫做Diagonals Deviation
的统计数据。后者主要展现坐标图中对角线上的像素之和,并测量这些和的标准误差。
下面是以上3种哈希函数的比较结果:
![](http://img.manew.com/data/attachment/forum/201501/28/094750ywqeltu2udkubu2d.jpeg.thumb.jpg)
最后PcgHash比较突出,尽管从上面的图片中看序列中的随机数噪点非常随机,坐标图却显示出清晰的模式,也就是说它经不住一些简单的变换。我据此总结,想实现自己的随机哈希函数挺难的,还是留给专家来解决吧。
MD5和xxHash似乎可以在随机性上相互媲美,而其中xxHash要快上约50倍。XxHash还有一个优点就是尽管它不是RNG,但还是有种子的概念,这并非所有哈希函数都具有。可以设置种子对程序生成来说如虎添翼,因为您可以使用不同实体、网格或类似对象的不同属性来作为不同的种子,然后只用该实体的索引(或网格)坐标作为哈希函数的输入。关键是,使用xxHash,不同种子生成的序列之间也是随机相关的(不同种子生成的序列之间也是随机的)(详情请查看附录2)。
哈希对程序生成的优化实现
从我对哈希函数的研究中显而易见的是,虽然与一般哈希函数性能基准相当,这是一个很好的选择,但至关重要的是,要对它进行优化来满足程序生成的需求而不是原样使用哈希函数。
下面是两点非常重要的优化
- 避免int(整型)和byte(字节型)间的类型转换。最常用的哈希函数都是以一个byte数组作为输入然后返回一个整型或一些字节的哈希值。然而,一些高性能的函数会将输入的byte转换为int,因为它们内部是操作int。由于最常见的程序生成就是根据一个int输入返回一个哈希值,所以完全没有必要转换到byte。去除对byte的依赖可以增加两倍性能同时保证输出完全一致。
- 实现不使用循环只有一个或几个输入的方法。最常用的哈希函数都是接收不同长度的数据作为输入,以数组的形式。这对程序生成也非常有用,但最常用的可能是只有1个、2个或是3个整数作为输入生成哈希值。以固定长度的整数而不是数组作为输入来优化函数,就不需再使用循环,这能显着提高性能(我测试大概是快4~5倍)。我不是底层优化的专家,但这显着的区别可能是由for循环的隐式分支或需要分配数组导致的。
目前我所推荐的哈希函数就是针对程序生成优化过的xxHash,更多详情请查看附录C。(目前我所推荐的哈希函数就是针对程序生成随机数优化过的xxHash)
您可以在 BitBucket上获取我写的xxHash及其它哈希函数. 这是我利用空闲时间写的属于自己的东西,非属于Unity Technologies.
另外我也添加了额外的方法来优化生成指定范围内的整数或是浮点数,这对程序生成来说也是至关重要的。
注意:在写这篇文章的时候我只添加了单个整数为输入的优化到xxHash和MurmurHash3。后续有空我会添加重载函数来优化两个及三个整数输入。
哈希函数和RNG相结合
随机哈希函数和随机数生成器是可以结合使用的。明智的做法是,使用不同种子的随机数生成器,但这些种子都是经由哈希函数转换过的而不是直接使用。
假设有一个很大的迷宫,接近无穷大。其中有个大型网格且每个网格单元也是一个迷宫。随着玩家在世界中移动,网格单元中的迷宫也要在玩家周围动态生成。
这种情况下您可能希望每个迷宫每次被访问时其生成方式都是一样的,所以就需要随机数的生成与之前生成的随机数毫不相干。
然而,迷宫是一次就全部生成的,所以对一个迷宫来说就不用控制各个随机数的顺序。
这里提供了理想的方法,就是用随机散列(哈希)函数根据迷宫网格单元的坐标来创建种子,然后将其作为随机数生成器的种子来生成随机数序列从而创建迷宫。
![](http://img.manew.com/data/attachment/forum/201501/28/094750y64styzt2zntvzkp.jpeg.thumb.jpg)
在C#中的代码示例如下:
01
02
03
04
05
06
07
08
09
10
|
RandomHash randomHashObject = new RandomHash ( 12345 ) ; int mazeSeed = randomHashObject.GetHash ( cellCoord.x , cellCoord.y ) ; 26 Random randomSequence = new Random ( mazeSeed ) ; int randomNumber 1 = randomSequence.Next ( ) ; int randomNumber 2 = randomSequence.Next ( ) ; int randomNumber 3 = randomSequence.Next ( ) ; |
结论
如果您要控制查询随机数的顺序,就使用合适的为程序生成优化过的随机散列函数(例如xxHash)。
如果您只是想要一串随机数而不在乎顺序,最简单的办法就是使用随机数生成器,例如C#中的System.Random类。为了所有随机数之间dous
随机相关的(随机的),就只生成一个序列(只用一个种子来初始化),或者使用随机散列函数(如xxHash)处理过的多个种子初始化。
本文提到的随机数测试的源码,以及大量的RNG和散列函数的源码,都可以在 BitBucket上获取。这是我在空闲时间自己写的,与Unity Technologies无关。
本文最初是发布在runevision blog ,博客专注于游戏开发及我空闲时的一些研究。
附录A:关于连续噪声的注解
对于某些情况您可能希望查询连续噪声值,就是说输入值相近且输出值也相近。典型应用就是地形或纹理。
这些需求与本文的讨论截然不同。对于连续噪声,要研究Perlin Noise——或更高级的Simplex Noise.
然而,要知道这些只适用于连续噪声。查询连续噪声函数只是为了得到与其它随机数无关的随机数,它生成的结果差强人意,因为这并不是这些算法优化的方向。例如,我发现在整数位置查询Simplex Noise函数,每隔3个输入就会返回结果0。
另外,连续噪声函数通常用浮点数进行计算,它的稳定性及精度都不如原来的高。
附录B:更多关于种子及输入的测试结果
多年来我听到过各种各样的误解,我会试着在这里列出一些。
难道用一个很大的数作为种子不是最好的吗?
不是,没有任何证据证明这点。从本文中的测试图片ke以看出,种子值的大小对输出结果没有影响。
随机数生成器不需要几个数来“开展工作”吗?
不用。再次说明,从本文中的测试图片可以看出,从始至终随机数序列都是相同的模式(左上角开始一行接着一行)
下面是我测试的65535个随机数序列中分别取第0个以及第100个生成的图像。可以看到,两者的随机性并没有多大差别。
![](http://img.manew.com/data/attachment/forum/201501/28/094749g9dd6wdt655ibvu6.jpeg.thumb.jpg)
就没有一些RNG,比如Java中的,使用不同种子的随机数序列随机性更好吗?
也许会有一点点优势,但这远远不够。与C#中的Random类不同,Java中的Random类不适用原来的种子,而是在存储种子之前打乱了种子的位顺序。
不同序列中的随机数看起来可能有一点随机,我们可以从测试图中看到Serial Correlation要更好。然而,从坐标图中可以很明显看出使用坐标时数字还是会排列成单行。
![](http://img.manew.com/data/attachment/forum/201501/28/094751g31576p7853k23t3.jpeg.thumb.jpg)
这就是说,我们有足够的理由,让RNG使用经由随机散列函数处理过的种子。实际上这样做似乎是个不错的主意,在我看来没有不妥。只是我所了解的目前流行的RNG都没有这样做,所以您得按照之前描述的内容自己实现。
为何使用不同种子适合随机Hash函数
这没有什么特别原因,只是xxHash和MurmurHash3之类的函数对种子及输入的处理类似,也就是说它本质上就是一个应用于种子的高质量随机Hash函数。因为它的实现方式如此,所以从不同种子生成的序列中分别取第N个随机数结果也是随机的。
![](http://img.manew.com/data/attachment/forum/201501/28/094751nechedkr8rkr4oid.jpeg.thumb.jpg)
附录C:更多Hash函数的对比
本文最初版我对比了PcgHash, MD5和MurmurHash3,并推荐使用MurmurHash3。
MurmurHash3的随机性和速度都非常优秀。作者同时提供了名为SMHasher 的框架来测试哈希函数,该工具已经被广泛使用。
这里还有一个很好的方法Stack Overflow question about good hash functions for uniqueness and speed ,其中对比了很多哈希函数,并且生成的图像与MurmurHash同样良好。
在发布本文后我看到了Aras Pranckevi?ius的推荐从而得知了xxHash,并且从Nathan Reed那里了解到了Wang Hash。
xxHash在自己的地盘击败了MurmurHash,因为其在SMHasher测试中的质量分很高,同时性能也更好。详情请查阅xxHash on its Google Code page.
在我最初的实现中,移除了字节转换之后,它比MurmurHash3更轻量更快速,尽管在SMHasher中看到的结果并非如此。
我还实现了WangHash。结果证明其质量不高,因为它在坐标图中展示出明显的模式,但它比xxHash要快5倍以上。我试着实现了“WangDoubleHash”,并将结果反馈,测试得出其质量不错而且依然比xxHash快3倍以上。
![](http://img.manew.com/data/attachment/forum/201501/28/094751nwkwasoav893q4wj.jpeg.thumb.jpg)
然而,由于WangHash(以及WangDoubleHash)只接收一个整数为输入,我决定实现同样只接收单个输入的xxHash和MurmurHash3
来看看性能会有什么变化。结果发现性能显着提高(大概4~5倍)。所有实际上xxHash是比WangDoubleHash更快的。
![](http://img.manew.com/data/attachment/forum/201501/28/094751rpdi3ziha7zybihi.jpeg.thumb.jpg)
说到质量,我自己的测试框架 有着很明显的缺点,但它没有SMHasher
测试框架那么复杂,所以某个哈希函数测试分高可以假定为其随机性好而不仅仅是在我的测试中看起来不错。一般我会说,经由我的测试框架测试过的函数都足以用于程序生成,但由于xxHash(优化过的版本)是最快的哈希函数并通过了我的测试,所以不用考虑了就用它吧。
还有非常多的哈希函数可以拿来做更多的比较。然而,我主要关注一些公认且被广泛使用的,随机性及性能都最优秀的方法,然后将它们优化后用于程序生成。
我觉得使用此版本的xxHash其性能是最佳的,并且想寻找或使用更好方法的可能性微乎其微。这就是说,随意扩展 测试框架来实现更多吧。