随机分形地形生成
发表于2016-09-11
1、最近在学习OpenGL的东西时,无意发现了一篇关于”分形“的文章。”分形“由于以前接触过一点,记得和”过程内容生成“有莫大的关系,它强大而神奇的功能一直让我很好奇。看了原文作者的这篇文章后,我断定这是一篇学习”分形“的入门级别的好读物。 虽然文章并没有过多解释关于”分形“的数学知识,但通过一个经典算法的学习,会让人对它的应用更有”通透“的理解。因此我决定将它译成中文,供兄弟们一起学习。
2、调子拔得有点高了,实际上这篇文章写于1997年,距离现在已经过去了12年的岁月。因此它是一个不折不扣的老古董。对此我有两个感触:一是国外怎么这么早就有人在研究这玩意,而直到现今我对分形才刚了解没多久,这当中的差距差了多少光年?二是我会不会有点傻x,有人会翻12年前的东西么?不过感觉对我个人的成长还是很值的。原文由Paul Martz所写,在此表示感谢,尽管他本人无论如何也不会知道。
3、这篇文章翻了大概有一个礼拜还多的时间(3月9日——3月18日)。当然和能力和效率有关了,这期间也还要处理很多很复杂的人生问题,包含有:为怎么找工作发愁,为怎么考研发愁,为怎么完成毕设发愁,和女友吵架N多,看《不能承受的生命之轻》,看押井守的《攻壳机动队》3部和《天空杀手》,看杨德昌的《一一》(全是小明推荐来的,呵呵,兄弟啊),和弟兄们交流这些糟心事,一起抽烟喝闷酒。这里权当日记小结一下,不打算阐述更加深刻的思考。
4、还不得不说下博客发布编辑器。我花了一周时间翻文章并不冤枉,可是当我把完成的东西从word里转帖到这里时,却花了一上午的时间,因为格式转换的问题,不得不手动去调,真是郁闷又冤枉。期间还用了SCribeFire,可也没啥用。现在word和在线发布真的无法无缝转换么,jesus christ 。
5、因此,发布成在线的html颇费体力,效果也不好,没办法大家凑合看吧。对于习惯于看双语的朋友我提供了pdf版本下载(点击这里下载)。另外我给出原文地址链接: http://gameprogrammer.com/fractal.html 。 想看E文的直接去看。
6、对于翻译,有些词语是我擅自理解翻的,也许保留它们的原文更好(相应在文中以后缀(斜体)给出)。另外有些不太容易理解的关键信息也保留了原文,但在这里不会列出。词语如下:
Generating Random Fractal Terrain 随机分形地形生成
The Definition and Rendering of Terrain Maps 地形图的定义和渲染
diamond-square algorithm “菱形-正方形” 算法 (开始我以为是什么“钻石-广场”算法,搞得一头雾水....-_-!)
tessellate 镶嵌
Mandelbrot set 曼德勃罗特集
Midpoint Displacement in One Dimension 一维“中点替换”算法
fractal image compression 分形图像压缩
Chaos and Fractals, New Frontiers of Science 《混沌和分形,科学的新前沿》(网上找了找这方面的资料,没发现有中译本,因此尚不知该书的准确译名是什么)
g.另外是我lookof自己,译者,擅自补充了一些话来让语句变得更通顺或更易理解的,或者是对此作出的一些解释。不得不说明原文并没有这么写,而且兴许我的理解有问题。不过我所作的补充大多都是无关紧要的环节,不会影响大家理解关键的算法思想。对于是关键环节而拿捏不准的地方,我都提供了原文。补充以后缀(下划线)给出。(注:这可不是超链接..... -_-!)
如: 查阅本文末尾的参考书目(对学习分形知识而言)是一个好的开始。
h.参考书目不译。
目录:
第一部分:随机分形地形生成
引言
自相似“一维中点替换”算法
高度图
“菱形-正方形”算法
天空云图
其他方法
第二部分:关于示例程序及其源代码
安装
使用示例程序
代码结构
下载示例程序
参考书目
第一部分:随机分形地形生成(Generating Random Fractal Terrain)
引言
10年前,我偶然发现了SIGGRAPH小组于1986年的会议记录,对其中的一篇文章感到特别得肃然起敬。这篇文章的题目是《地形图的定义和渲染》(Definition and Rendering of Terrain Maps),作者是Gavin S.P.Miller1 。该文描述了很多分形地形生成的算法,而且作者也介绍了一种新的算法。小组其他成员认为,这种新算法较之以前是一种改进。
起初,我对这些算法的印象非常深刻(尽管作者认为这些算法依然有“瑕疵”),它们竟然能创造出如此令人难以置信的风景图片!然而接下来随着深入文章,我被这些精巧绝妙的算法“惊呆”了。
自那以后我就对分形地形上瘾了。
隐藏在该算法背后的数学知识可谓相当复杂。尽管如此(幸运的是),能不能完全弄清楚这些数学理论对学习该算法并无影响。这很好。因为假如我在解释算法之前,不得不先解释所有这些数学的话,那我们这辈子也接触不到这个算法了。除此之外,涉及到分形的数学概念光从字面上统计就有成吨的那么多。查阅本文末尾的参考书目(对学习分形知识而言)是一个好的开始。
出于同样的原因,我不会深入到数学细节里,我无法在这里包含一个分形理论的概观,也无法罗列出每一件它们能够做的事情。取而代之的是,我将描述隐藏在“分形地形生成”背后的概念,然后着重给出一个我个人最喜欢的算法:“菱形-正方形”算法(diamond-square algorithm) 。我会告诉大家如何利用这个算法来静态地“镶嵌”(tessellate)一组高度数据,这组高度数据可以用来生成几何地形数据,地形纹理贴图,以及云团纹理贴图。
利用分形地形你可以做什么?我假设你已经知道了答案,因为这就是你为什么会读本文的原因。随机地形图对于飞行模拟器,以及制作一块纹理贴图来当做背景(比如显示一座遥远的山脉)来说,具有非常好的效果。同样的算法也可以用来为天空云团生成纹理贴图。
在我继续讲下去前,发布一条免责声明:我不是一个游戏程序员。如果你是因为想要一个更快的渲染地形的算法而读此文的话,你来错了地方。我只是介绍生成这种地形模型的过程。至于你如何渲染,取决于你自己。
自相似
藏在任何“分形”背后的一个关键概念是:自相似。当一个物体放大它自己后,它的局部子集相似于(或者相同于)整体和周围的局部子集。
想一想人体的循环系统,这就是一个自然界中“自相似”的好例子。从最大的动脉和静脉开始,一路直下到最小的毛细血管,都是一模一样的枝杈结构。如果你不知道你是在用显微镜观察它们的话,你就会分不清哪个是毛细血管,哪个是动脉。
现在考虑一个简单的球体。它是自相似的吗?不是。 非常显著地放大很多倍以后,它就不再像个球了,而是像一个平面。如果你不相信我,只要看看外面好了。除非你阅读本文的时候正好位于外太空的轨道上(汗。。。太幽默了),否则你将看不出任何关于“地球是圆的”这一迹象。 球体是非自相似的,描述它的最佳途径是传统的欧几里德几何,而非分形几何。
地形算是“自相似”这一范畴。你手中握着的岩石断面的锯齿边有着相同的不规律性,就像遥远地平线的一条山脊一样。(因此地形的这个特点)就允许我们利用分形思想来产生仍然像地形的地形,而不用考虑地形呈现时的缩放比例。
一条关于“自相似”的旁注:最严格的意义,它指的是“自相同”,就是说,无论放大或是缩小多少倍,它自己的精确的微观拷贝版本都是可见的。(that is, exact miniature copies of itself are visible at increasingly small or large scales.)(我自己的理解是,它的局部是严格相同于整体的,即局部和整体一模一样,并且可以无穷无尽细分下去)。实际上我不知道自然界中是否存在“自相同”的分形现象。但是曼德勃罗特集(Mandelbrot set)是自相同的。我不能再深入去讲曼德勃罗特集了,更多信息请查阅参考书目。
“一维中点替换”算法(Midpoint Displacement in One Dimension )
我稍后会讲到的“菱形-正方形”算法,使用了一种二维“中点替换”算法。为了帮助你掌握它,我们首先看看在一维下的“中点替换”算法。
一维“中点”替换算法是在遥远的地平线上画一条山脊线的极好算法。它是这样工作的:
以一条水平线段开始.
重复很多次{
对场景中的每一条线段{
找到线段的中点.
在y轴上用一个随机值替换中点值.
缩小随机值的取值范围.
}
}
你缩减随机值取值范围的幅度是多少?这取决于你想要的效果有多“粗糙”。每一轮你缩减的越多,最后的山脊线就会越“平滑”。如果你基本不缩减,最后的山脊线就会像锯齿一样参差不齐。这表明你可以把“粗糙度”设定为一个常数,我呆会儿会解释怎么做。
我们来看一个例子。现在我们有一条线段,X轴上它在区间[-1.0,1.0]里,Y轴上每一处都是0 。 开始的时候,我们设定一个随机值的取值范围[-1.0, 1.0](可以随意设)。 所以我们将在这个范围里产生一个随机值,然后用这个随机值替换中点值。完成这步后,我们有:
现在开始第二轮循环。我们这时有两端线段,每一段都是原始线段的一半。我们让随机值取值范围也减半,所以现在变成[-0.5,0.5] 。我们为两个中点值各自在这个范围里生成一个随机值以之替换。现在的结果是:
我们再次缩减随机值取值范围,因此现在它变成了[-0.25,0.25] 。在这个范围里再生成四个随机值,用来替换现在的四个中点值,完成后我们有:
你应该注意两点。
首先,它是递归的。而事实上,它也可非常自然地用迭代手法来实现。因此在(一维画线)这个例子里,无论是递归还是迭代都可行。但是对于生成“表面(即地形)”的代码而言,用迭代实现比用递归实现有更多的好处。因此为了(画“线”和画“面”)一致起见,给出的示例程序中,无论画“线”还是画“面”,都是用迭代方法实现的。
其次,这是一个非常简单的算法,然而,它却创造处一个非常复杂的结果。这就是分形算法的魅力所在。几条非常简单的指令就能创造出一个非常丰富而且细腻的图像。
这里我扯句题外话:只要用一组精巧的指令集就能创造出一幅复杂图像的实现,引领起一个新技术领域的研究,被称为“分形图像压缩( fractal image compression)”技术。该技术的思想是,存储用来创造图形的简单而又递归的指令,而不是存储图像本身。这种技术对于创造出自然界中真正有“分形”现象的图像而言,效果是极其好的,因为指令比图像数据占据的空间要小得多。《混沌和分形,科学的新前沿》3 (Chaos and Fractals, New Frontiers of Science 3 ) 有一个章节和一个附录是专门讲这个话题的,一般来讲对任何一个“分形迷”而言,都值得一读。
言归正传。
不必费多少劲儿,你就可以把这个(一维“中点”替换)函数的输出结果读入到一个绘图程序里,来生成点类似这样的东西:
这种图像可以用来,打个比方说,作为一幅窗外的风景。于此一件不错的事情是,它是环绕的(即把两个边对接起来是契合的),这样你就可以用一张相对较小的图片来包裹整个场景。当然,如果你不介意无论从哪个方向看,都会看到同一座山的话。
接下来,在我们进入“二维分形表面”这一话题之前,你需要了解下什么是“粗糙度常数”。这是一个决定每次循环时随机值取值范围应当缩减多少的值,因而这个值会决定最终结果的粗糙程度。示例程序中我们使用了一个在区间[0.0,1.0]之间的浮点型数,我们称它为H 。那么2^(-H) 就是一个在区间[0.5,1.0]之间的值。随机值取值范围在每一轮循环中都会乘以这个值(指的是2^(-H))。当H设置为1时,随机值取值范围每轮循环都会减半,得到的结果是一个非常光滑的不规则形状;当H设置为0时,取值范围则根本不会缩减,得到的结果将是非常参差不齐的锯齿状形状。(这里关于H的理解有点搞头,不过经过本人详细推敲,这段话是没有问题的。H无疑是指粗糙度常数,但随机值取值范围每次乘的数是2^(-H) 。比如就像上面说的,如果你定的这个常数为1,那么就会使随机值取值范围每次都乘1/2,即第一次在[0,1]内取一个随机值,第二次在[0,0.5]内取,第三次在[0,0.25]内取。这样看来,H是通过控制2^(-H) 来间接控制随机值取值范围的。如果兄弟们还有不明白的可来信询问。:-))
以下是随着H值的变化,所渲染出来的不同的山脊线:
高度图
上面描述的“中点替换”算法使用了一个表征高度值的一维数组来实现。这个高度值指的是每条线段端点的垂直位置。该数组实际上就是一维的高度图。它反应的是X轴索引到高度值Y的映射。
为了模拟随机地形,我们欲把上面那个算法(指“中点替换”算法)推广到三维空间,而且为了达到这个目的,我们需要一个表征高度的二维数组。这个数组把X轴和Z轴的联合索引映射到表征高度的Y轴上去(就是说Y是X与Z的多元函数,高数里的内容)。注意,虽然我们的最终目标是要产生一个三维坐标信息,但这里这个二维数组仅仅存储Y轴上的值,X轴和Z轴的值我们可以在解析数组的时候即时动态地生成。
通过为每一个高度值分配一种颜色,你可以把一幅高度图显示成一幅图片。下面的这幅高度图,用白色表示地形中高的地方(Y值大),用黑色表示地形中低的地方(Y值小):
用这种方式渲染一幅高度图对生成“云团纹理贴图”也是有效的,这个呆会儿我会解释。同样,这种方式也可以用来随机生成一幅高度图(个人认为,这才是这篇文章的精髓。因为后面介绍的算法就是在解决“随机生成一幅高度图”这个问题的)。
现在,我来介绍如何生成“存储高度图信息”的二维数组。(即如何随机生成一幅高度图。)
“菱形-正方形”算法
正如我在本文开始的时候提到的,我是在Gavin S. P. Miller 的文章中第一次知道了“随机地形生成”这个概念。具有讽刺意义的是,在文中,Miller把“菱形-正方形”算法看作一种存在“缺陷”的算法,之后他就开始介绍另一种基于“加权平均和控制点”的算法了。(and he then goes on to describe a different algorithm based on weighted averaging and control points. )
(下面一段话,考虑到自己翻功拙劣,于是原文附上。因为我并不能太好地表达他的意思。好在这段是评论Miller对该算法的看法的,不是理解算法的关键。)
Miller对“菱形-正方形”算法的抱怨根源于他尝试利用该算法来生成一座山。(山会坐落在X轴和Z轴所代表的平面网格中)。他要求平面网格的中心位置的高度(也就是峰值)是人工赋值的,而其它所有点的高度都是随机产生的。如果Miller当初仅仅让中心点的高度也同样随机产生,那么连他也会不得不承认,该算法就像一个地形生成器一样工作得那么优雅。“菱形-正方形”算法可以用来生成一座带有一个峰顶的山,这是通过对数组“种入”随机数来办到的。当然不仅仅是只对数组的中心点这一个点植入一个种子数来达到可接受的结果。(呃,太绕了,就是说其它的点也需如此吧。建议大家看原句,我好菜。)他也对其它固有的折痕问题发了点牢骚。但是(不管怎样),你该有自己的判断。这个算法最初由Fournier, Fussell, 和Carpenter 所描述4。
(Miller's complaints with the diamond-square algorithm stem from his attempt to force the algorithm into creating a mountain, that is, with a peak, by artificially increasing the height of the grid center-point. He lets all other points in the array generate randomly. If Miller had simply generated the center-point randomly, then even he would've had to admit that the algorithm works pretty decently as a terrain generator. The Diamond-Square algorithm can be used to force a mountain with a peak, by "seeding" the array with values. More than just the center point of the array must be seeded to achieve acceptable results. He complains of some inherent creasing problems as well. But you judge for yourself. The algorithm is originally described by Fournier, Fussell, and Carpenter 4.)
算法思想是这样的:你以一个大而空的代表点的二维数组阵列开始。有多大?简单起见,这个阵列应该是个正方形。而且每一维的尺寸应该是2的几次方再加1(比如33x33, 65x65, 129x129等等)。将四个角落的点设成相同的值。看看你得到的东西,它是一个正方形。
举一个简单的例子,让我们使用一个 5x5的数组。(我们将在后面谈到这幅图像,所以别把它忘到脑后。)在图a中,被赋值的四个角落的点用黑色高亮显示:
下面是算法的起点,共分为两个阶段:
“菱形”阶段:利用构成正方形的四个点,在这个正方形的中点,也就是两条对角线交汇的地方,生成一个随机值。中点值等于,四个边角点值求平均后再加上这个随机值。当在网格中有多个正方形时,这样就会为你产生菱形。(这里讲的很模糊,我也是摸索了很久才弄明白作者指的是什么意思。这里给一点提示,看着图b,用眼睛把左边的两个边角点,连同那个中心点连起来,顺序是:左上点->中心点->左下点。这样你就得到了一个菱形的右半部分。看出来了吗?它的左半部分呢?其实是循环顺延到了右边,也就是右上点->中心点->右下点构成了这个菱形的左半部分。不过,在作者看来,半个菱形就可以算是菱形了。所以,这里一共有四个菱形:左上点->中心点->左下点、右上点->中心点->右下点、左上点->中心点->右上点、左下点->中心点->右下点。)
“正方形”阶段:利用构成菱形的四个点,在这个菱形的中点生成一个和上一步相同取值范围区间的随机值。同样这个中点值等于,四个边角点值求平均后再加上这个随机值。这样就又会给你产生正方形。(看着图c,左上点->中上点->中心点->左中点构成了一个正方形,容易看出该图一共排布着4个这样的正方形。)
所以,如果你“种下”1个正方形并且只做了一轮这样的循环后,你将最终得到4个正方形。两轮循环你就得到16个正方形,三轮就有64个了。它增长的很快。正方形的数量等于 2^(i*2) ,i是循环迭代的轮数。(The number of squares generated is equal to 2^(I+2), where I is the number of iterations through the recursive subdivision routine.)(必须指出,我翻的和原文并不一致,尤其是在那个关键公式上有些出入。原因是,无论我怎么理解,我都搞不清楚2^(I+2)的意义。如果2^(I+2)指的是得到的正方形的个数, I无论指什么意思,都不能满足1,4,16,64这样的增长规律。因为I增长必是按1累加的,即1,2,3这样的顺序,可是当I等于1时,结果就会出现8,这是不可能的。如果出现预期的结果,那么I必须以2累加,即0,2,4等等,可是这时I又表明什么意思呢?无法解释。唯有把公式解释为2^(I*2),一切疑问都烟消云散。这时I指的是迭代次数。I=0时(还没有迭代),只有1个正方形;I=1时(第一次迭代),出现4个正方形;I=2时(第二次迭代),16个正方形;I=3时(第三次迭代),64个正方形。这样就很好,一切都非常和谐。关于此疑问我已给原作者发了询问邮件。不过,对于他是否能够回复,我和兄弟们一样期待。。。。-_-!)
现在我们对照前面5幅图,逐一看看当我们执行“菱形-正方形”算法的那两个阶段时发生的一切。
对于第一轮的“菱形”阶段,我们在这个数组的中心(即正方形的中心点)生成了一个值,这个值是基于四个边角值得出的。我们计算四个边角值的平均值(如果这四个值相等的话,这一步其实是不必做的),然后加上一个范围在区间[-1.0,1.0]里的随机值。如图b所示,新产生的点用黑色表示,已经存在的点则用灰色表示。
接下来是“四边形”阶段。我们在和上一阶段相同的随机数取值范围内生成一个随机值。在这个阶段一共有四个菱形。它们全部相交在原正方形的中心点。所以我们要分别计算这四个菱形的中心点。对每一个菱形,求它的四个边角值的平均值,再加上那个随机值,就是各自中心点的值。如图c所示,新产生的点用黑色表示,已经存在的点则用灰色表示。
这就是第一回合。如果你用线把这九个点连起来,你会得到像下面这样的线框图:
现在让我们进入第二回合。我们再次以“菱形”阶段开始。第二回合在两个方面区别于第一回合。首先,我们这次有了4个正方形,而不是1个。所以我们需要分别计算这4个正方形的中心点。其次,同时这也才是最关键的,这次随机值的取值范围区间要被缩减。比如拿这个例子来说,我们设置的H值为1的话,那么随机值的取值范围就会从(-1.0 ,1.0),缩减到(-0.5,0.5)。如图d所示,我们计算的四个正方形的中心点用黑色表示。
最后,我们进行这一回合的“正方形”阶段。因为一共有12个菱形中心点,所以我们现在需要计算12个新值。图e中用黑色标出了它们。
现在,这个数组中的25个点已经全部生成。现在我们也许会得到如下的一幅线框图:
如果分配一个更大的数组,我们就能继续循环更多次的回合,在每轮回合中增加更多的细节。比如,经过5轮回合,我们的表面也许看起来会像这样:
我之前提过,数组中每一维的尺寸应该是2的几次方再加1。这是因为,一个二维数组需要的浮点型数的个数是(2^I+1)^2,8轮回合下来,就会需要一个257x257大的数组。这对于32位的IEEE浮点数类型来说,会吃掉比256KB还多的内存。
这是一笔很大的开销。使用字符类型代替浮点类型会有所帮助。示例程序用的是浮点型。但如果内存对你来说真的很吃紧的话,你就得用字符型,用一个范围在-128到128的有符号字符类型来替换示例程序中的浮点型应该很简单。但是当你生成它们时,要小心夹紧这些值:即便在第一回合你把它们限制在-128到128内,在随后的循环中,仍然有可能超出这个范围,由此产生一个“溢出条件”。这在当H较小时尤其可能发生。
示例程序告诉我们另一种解决空间问题的方法。首先我们分配一个很大的数组,然后用“菱形-正方形”算法来为其赋值。之后用“从顶部到底部”的正投影视角将这个数组渲染成一幅图像,然后把这幅图像作为纹理贴图来回读给第二个只需占用较小内存的数组。一旦渲染出来的那副图像从帧缓冲区那里被回读完毕,你就可以释放第一个占用很大内存的数组了,尽管示例程序并没有这么做(指释放内存)。
下面是这种纹理贴图的一个例子:
这张图人为地把峰顶涂成白色,把峡谷涂成绿色,二者之间的部分则涂成灰色。你可以用示例程序提供的的源代码来随意实施你自己的配色方案。
(下面这段位于两个分界线的文字我仍然附上原文(汗颜....),并且原文在上,翻译在下,上下对比以作参考。原因是作者文字之简单但意图之模糊相当打击本人信心。我觉得自己没有能力很好地理解他到底是什么意思,但依然试图翻一翻,并且根据意思和一些有限的推理作出大胆假设。这段信息后面又有一段位于两个分界线的文字,那是我对此段文字的一些理解。如果只看原文就懂的朋友可绕过理解文字不看,不过我希望还是看看,帮忙校正一下是不是我在扯淡。)
Earlier I had mentioned that there are advantages to implementing this routine iterative rather than recursive. Here's why: A recursive implementation might take the form:
Do diamond step.
Do square step.
Reduce random number range.
Call myself four times.
That's a nice simple implementation, and I have no doubt that it would work. But it requires that some points be generated with insufficient data. Why? After the first pass, you'll be called upon to perform the square step without having all four corners of a diamond.
Instead, I've implemented this iteratively, and the basic pseudocode looks like this:
While the length of the side of the squares
is greater than zero {
Pass through the array and perform the diamond
step for each square present.
Pass through the array and perform the square
step for each diamond present.
Reduce the random number range.
}
This eliminates the problem of missing diamond corners found in the recursive implementation. But you'll run into this problem again anytime you generate a point on the edge of the array. It turns out this is only a concern in the square step. You can easily overcome this and simultaneously make the surface wrappable by taking into account that one of the four diamond corner points lies on the other side of the array. (Another key to making the surface wrappable is to remember to seed the four corners with the same value.)
之前我说过,用“迭代”比用“递归”更好。因为,一个“递归”手法的实现很可能是这样的形式:
执行一步“菱形”阶段的操作
执行一步“正方形”阶段的操作
缩减随机值取值范围区间
调用自己四次
这个实现很简单,而且我也不怀疑它能运行。但这个方法的漏洞是,其中有一些点要在数据不足的情况下被生成。为什么呢?在执行完第一阶段后,你还尚未得到菱形的全部四个边角值时,就要执行“正方形”阶段了。
相反,我用“迭代”手法来实现这个算法的话,基本的伪代码类似这样:
当正方形的变长大于0时{
传递数组,为当前每个正方形执行“菱形”阶段。
传递数组,为当前每个菱形执行“正方形”阶段。
缩减随机值取值范围区间
}
这样就解决了用“递归手法实现时“丢失的菱形角落”的漏洞问题。但是(即便你用“迭代”方法实现)只要你在数组的边缘上生成一个点时你会再次碰上这个问题。不过这个问题只会在“正方形”阶段出现。你可以很容易地解决这个问题,你可以把这个表面“包裹”起来,假想菱形其中的一个角落(丢失的那个角落)落在了数组的另一边上。(另外使表面可以被“包裹”的一个关键是(在数组的四个角上)“种下”相同的四个值。)
-------------------------------------------
(以下是我对上面文字做的理解)
-------------------------------------------
我对作者的话存在两点疑惑:
1. 作者先说递归也是行得通的( I have no doubt t hat it would work. ),但又说这样会生成一些“残疾”的点,因为这些点是在数据不足时产生的( it requires that some points be generated with insufficient data.)。点是生成了,但却是残疾的,那么这个实现方法到底算“成”还是算“不成”呢?作者告诉我们,采用“迭代”比采用“递归”更优,开始我以为是性能效率上的比较,因为我们知道,“非递归”的开销确实要小于“递归”的开销,“递归”仅仅是代码上看上去简洁了不少而已。但是作者介绍到这里时,却到了伤筋动骨的地步,简直感觉如果用“递归”的话就会残疾了似的。到底是怎么回事呢,弄不清楚。
2. 对比递归的伪代码和迭代的伪代码,我没看出什么本质的不同,如果是区别在于后者传递了数组的话,我完全可以把数组设计成一个全局变量,然后同样传递给递归函数,这不同样可以办到么?因此我想这不是递归的死穴。另外,我不清楚为什么要“调用自己四次”??
经过一段时间思考,我忽然得出这样的猜测(不保证正确,这里正是需要大家拍砖校正的地方):
这就像是当初学数据结构时关于“图的遍历”的两种方法似的:深度遍历和广度遍历。这里的“递归”实现就像是深度遍历一样,它会盯住一个根“菱形-正方形”结构,逐步分解成更小的“菱形-正方形”结构(通过不停压栈),直到到达最小(这里调用四次即到达最小),然后返回上一级,然后遍历完这一级所有的结构,然后再返回上一级,直到栈被弹空。这样带来的问题是,当要求其中某一个结构的中心点时,会遇到围绕这个结构的四个边角,有一个角落点是不属于这个大结构的情况(那个角落点属于邻近的那个大结构里的点),但是由于“递归”的算法,你在当前大结构未被返回前,是无法计算邻近的那个大结构的点的值的,因此就带来了“丢失的角落”问题。这属深度遍历的特性所致,可谓死穴。
而迭代就像广度遍历那样,先把当前一级的所有结构都遍历完毕,才深入到下一层去遍历更小的结构,这样当下一层的小结构需要用到邻近的边角值来计算中心点时,因这些边角值属上一层的点,已被完全计算出来,因此就不存在什么障碍了,这样就很好地解决了递归的死穴问题。
下面是一个“菱形的一个角落出现在数组的另一边”的例子。图中,我们在“正方形”阶段生成了一个点,而它恰好落在了数组的边上。灰色表示的是包含了菱形四个角落的位置。这四个位置的值需要求均,以此作为中间新值的基值,图中那个新值用黑色表示。
注意,图中用黑色表示的有两个点。实际上它们是相等的。每次你在“正方形”阶段计算一个落在数组边上的点时,记得在数组对面的一边也要存储它一遍。为了能够无缝对接,这些点必须完全相同。
这就意味着,在前面的图e中,我们其实不必把那12个点值全都各自算一遍,因为其中有4个点值是另外一边对应点值的重复。因此我们只有8个点需要计算。
我将给有兴趣的读者留一个练习:修改示例程序中的源代码,让它在不需要计算“数组边上的重复点值”的情况下也能运行。(“计算重复点值”这件事)对于算法来说真的不是必须的,只是我恰好在代码里写成了那个样子。
如果直到现在你还没有运行过示例程序,也许现在你该打开它看一看。这个程序的初始状态是一个经过两次迭代而生成的表面。它是用“线框模式”渲染的,也就是简单地用线段连接起数组中的每个点而已。数组中的值被当作Y值对待,而每个点的X轴和Z轴的坐标信息在数组解析的过程中动态生成。用三角形(作为基本图元)可以很容易渲染图像,你可以把每一个正方形刨切成两个三角形(连接正方形的对角线即可得到)。“三角形”一般来说是用来渲染图形的非常优秀的基本图元,因为它永远都是“凸面”的,而且保证三个点绝对在同一平面。
点击进入“View Options ”对话框。改变“Random seed ”值可以让你生成一个不同的表面,稍稍调高点“Iterations”值,可以为表面增加更多的细节。代码中规定这个值的上限为10,此上限对于我的32M内存,奔腾Pro系统而言已经有点吃不消了,不管怎么看都是黑黑的一片(which is a little much for my 32 Meg RAM Pentium Pro system and just looks black anyway. )(Meg ,应该是megabyte的缩写,意为“兆字节”)。 (五年后,人们会在新的处理器和更高分辨率的屏幕上运行这段代码,然后他们会奇怪干嘛我非要把这个值卡在10以内....) (实际上,这篇文章是写于997年。如今12个年头过去了。我在我的1GM内存,Pentium M 1.73GHz,ATI X600 的机子上运行这段程序,把“Iterations”调到10后得到的依然是一片黑色.....)
第一个H值控制着表面的粗糙度。默认值设为0.7 。 试着调高或调低这个值,然后注意观察结果。
是的,这个算法偶尔会出现局部“突起”和一些“褶皱”。但是我比较喜欢这些超现实的“突起”效果,而“褶皱”并不明显,这取决于你从哪个角度去观察它,也和你从上面飞过它时有多快有关系(估计是用来作为飞行模拟的场景时)。( and the creasing is not obvious depending on what angle you are viewing it from, or how fast you're flying over it.)
天空云图
现在我们知道如何来生成一个表面。我们既可以生成并渲染成千上万个三角形,也可以把一个高解析度的纹理贴图贴到一个低解析度的表面上。(这两种方式)无论采用哪一种,都可以营造出非常酷的效果。但是现在我们该怎么生成头顶上的云团呢?实际上它比你想的要简单的多。
用“菱形-正方形”算法进行“镶嵌”过的数组,非常适合用来描绘一幅天空云团的纹理贴图。只不过这时数组的值,不再代表高度图里Y轴的值,而是表示云团的透明度。最小的值表示“最蓝”,是天空中最晴朗的部分;而最大值表示“最白”,是天空中云层最密布的部分。
轻轻松松解析完数组,你将得到这样一幅纹理贴图:
这幅图和本文前面说过的“高度图”很类似,但我把低端和高端的值保持在某个范围,以此来生成一片“云彩斑斓”的天空(but I have clamped the low and high values to create patches of clear and clouded sky.)。
运行示例程序,你也可以生成像上面的这样一幅图像。选择 Select rendering type 下拉框,选中2D mesh / clouds选项。(默认状态下它看上去类似像素那样一格一格的,试着把 Cloud iterations值调到8或者更高看看)。接下来设定不同的H值,这样就能得到不同的云团效果。
如果你回到这篇文章刚开头的地方,你会发现我把所有曾讨论过的东西都组装在了第一张图里。天空是用上面展示的纹理贴图生成的,在一个八棱锥上平铺了多次(The sky is made with a texture map as shown above, tiled multiple times over an eight-sided pyramid. )(意思是,天空的效果是用八棱锥做的一个天空盒,然后在上面平铺了很多这个贴图而做成的)。表面的几何体是用高解析度纹理贴图渲染的。该纹理贴图由“自顶向下”的正投影视角渲染。这幅图像经过“回读”后被当作一张新贴图使用。(就是前面说过的那个节省内存的技巧。)
示例程序会展示几乎所有在本文中所提到的图像。
其他方法
你也许想对“地形生成”施加更多的控制,超过示例程序所提供的功能。比如说,你也许想在开始的几轮回合中先在数组中“种下”一些你自己规定的值,这样像山、峡谷、等等什么的都可以按你的设计来放置。然后再用“菱形-正方形”算法来填充剩余的细节。
不要像Miller那样,想要生成一座山,却仅仅只对数组的中心点赋一个很大的值。想要产生合理的结果,你起码应该要(用“菱形-正方形”算法)为这个数组“播种”两到三轮。
你很容易就可以通过改写代码,让数组中已经赋了值的元素不再被分配新值。首先,初始化你的数组,比如说把每个元素都设为-10。接着,最初的几轮迭代用你自己指定的值来“播种”。然后,用那段改写过的代码来只对值为-10的元素分配新值。最初的几轮迭代不会生成任何值,因为你自己规定好的值已经在那了。随后的迭代中,才会根据你规定好的这些值来生成新值。
如何创造自己的“种子”? 如果你想要的形状是某种已知的可以用数学公式描述的形式,比如正弦曲线,那么直接使用公式就可以生成这些值。但要是别的情况,就得想一些“创造性的办法”来完成它了。我曾见过的一个办法是用“灰度值”来填充你自己的高度图。把“灰度值”和“高度值”进行一一映射,然后存储在你的数组中。接下来再用“菱形-正方形”算法来增加更多的细节。
除了使用“菱形-正方形”算法,你还可以使用许多其他算的法达到同样的目的。
(以下两段话我不太明白什么意思,不保证翻译质量,遂附上原文。并请大牛拍砖指正。)
(有一种算法是这样的:)在二维数组中随机选取一段范围,为该范围内每一个元素赋一个很小的值。不停地重复这个过程,分别为每一段随机选中的范围里的所有元素都增加一个很小的值。这样也会产生不错的结果,但是该算法的复杂度并不是线性的。如果计算的时间成本对你来说无所谓,那我鼓励你试试这种算法。(With successive random addition, a random region of the 2D array is incremented by a small amount. Repeat this process over and over, adding a small amount into each randomly chosen region of the array. This generates good results but is not computationally linear. If compute time is not a concern, I encourage you to try this algorithm out.)
另一种相似的算法是在数组中制造出一个“断层”,然后为其中一边的所有元素赋值,就像“地震”发生时那样。这个过程也需要重复好多次。同样该算法的复杂度也不是线性的,而且你必须迭代几轮才能得到像样的结果。(Another similar method involves making a "fracture" across the array and incrementing one side of it, as if an earthquake had occurred. Again, repeat several times. This is also not a linear algorithm and takes several passes to get acceptable results.)
欲了解更多其他的方法,请查阅后面的参考书目。
第二部分:关于示例程序及其源代码
安装
示例程序及其源代码打包在一个zip文件里。使用你最喜欢的zip解压缩软件来解开它。如果你还没有一个zip解压缩软件,可以试试PKware. (我们使用winrar就能打开。)
源代码使用OpenGL API 作为渲染的图形接口。如果你的机器还没有安装OpenGL,你应该先得到它。Microsoft和SGI 都提供了支持Windows 95 的OpengGL版本。但我劝你选用SGI的,因为它在性能和程序健壮性方面都比Microsoft的强出去不少。示例代码链接的是SGI实作版本。因为SGI和Microsoft选择了不同的名字来命名它们的DLL文件,所以代码使用的是SGI的DLL文件。
下面是“傻瓜式制作酷图像”的说明向导。
双击 Fractal Example 图标。
打开View Options 对话框。
从Select render type下拉菜单中选择2D mesh / rendered。
在 Iterations 框中输入4 。
在 Tile 框中输入3 。
点击 OK 。
使用示例程序
默认状态下,程序显示的是二维网格线框模式。它已经是用“菱形-正方形”算法生成了的。两轮迭代能产生16个正方形。(Two passes were made over the surface resulting in eight squares.)(这又是个错误,原文中说是8个正方形,但是按照正确的思路,两轮下来能产生16个正方形。运行程序你也能看到,确实是16个而不是8个。这里应该是作者的一个笔误。)
你可以使用箭头键来改变视角。使用←和→键可以旋转场景。按住Shift键再按↑和↓键可以令场景向上或向下运动。松开Shift键再按↑和↓键可以令场景向前或向后运动。
现在解释View Options 对话框。你可以在菜单栏里点击View 选项,或者按Ctrl-O(注意是O不是0)来打开它。这里你可以改变你要想看到的东西,以及为“如何生成它们”设定其属性值。对话框看起来像这个样子:
Select rendering type 下拉框菜单控制你要显示什么图形。1D midpoint displacement 渲染了一条用“中点替换”算法渲染的线段。
所有标明有2D mesh 的类型都是利用“菱形-正方形”算法生成的一幅图像。2D mesh / lines 是用线框模式渲染的一个表面。2D mesh / rendered 展示了一个“二维地形”,这个面是用当前纹理贴图(就是选项“2D mesh teximage ”中生成的那个贴图)贴出来的,而天空是用“云团贴图”贴出来的。2D mesh / clouds 允许你只观察“云团贴图”。这是一个简单的二维高度图,蓝色表示小值,白色表示大值。2D mesh teximage 允许你只观察在rendered 模式中展出的那个覆盖地形的纹理贴图。这是一个“自顶向下”观察的正投影表面。使用了不同的颜色来区别这个面不同的“高度值”。
你可以使用对话框右半边的参数,以此控制这些图形的生成。
参数Tile 表示需要“排布”多少个表面或者线段。默认值是1。对于线段,这个参数决定了会平铺多少条单位为“1”的线段;而对于表面,把这个参数设为2则表示有2x2个单位为“1”的表面,3则表示有3x3个。如此等等。
参数Random seed设置了一个新的“随机种子”,以此可以创造出不同的表面。
参数 Iterations 决定会迭代多少次。数值越大则细节越多。默认值是2,这个参数的取值范围是从1到10。
你可以调节参数Cloud iterations来设置“云团贴图”的细节度。类似地,你也可以调节参数eximage iterations 来设置“地形贴图”的细节度。
注意,对话框里一共有三个H值。第一个H值用来生成表面,第二个H值用来“云团贴图”,第三个H值用来生成“地形贴图”。
当渲染类型选择1D midpoint displacement 或者2D mesh / lines时,勾选Antialiased lines 复选框会采用抗锯齿模式。
勾选Invert colors复选框会反转背景和线条颜色。
当渲染类型选择2D mesh / rendered时,Texture linear复选框控制“双线性纹理滤波方式”的开关。勾选此开关后,结果的生成会耗时更长,但最后的图像素质也会更高。
代码结构
文件fractmod.c 和文件fractmod.h是这个示例程序的C程序代码。 他们包含了“分形生成”模块。
微软1996年11月的期刊上(http://www.microsoft.com/msj)刊载了一篇文章,提到 CFractalExampleView 类是COpenGLView 类的派生类。COpenGLView类的作者是Ron Fosner,他把这个类描述成原成熟类的“黑客版本”(The COpenGLView class was written by Ron Fosner, who describes it as a hacked version of his fully-blown COpenGLView class)。(直到现在,微软期刊上仍有这篇文章,点击这里查看。)想要知道到底怎么回事,你得买一本他写的书《Programming for Windows 95 and Windows NT》,该书由Addison-Wesley出版。
该COpenGLView类有一个虚拟成员函数RenderScene,在CFractalExampleView类中我们重写了它。这里我们做了大部分的渲染工作。这个函数首先检查“渲染类型”。当设置为2D mesh / lines 或者1D midpoint displacement时,那么仍由原RenderScene来处理。否则的话我们调用另一个函数。
函数CFractalExampleView::OnViewDialog 用来生成View Options 对话框,以及在对话框类和CFractalExampleView类之间设定和检索数据。
函数CFractalExampleView::OnInitialUpdate负责把所有CFractalExampleView类中的成员变量设定为默认值(包括对话框中的值)。
其实真没什么必要去更深地解释代码是如何工作的。我假设你是一名很有能力的程序员,而我也尽了最大努力来全面地讨论这些代码了。如果你不熟悉OpenGL,那你也许很想知道的是,那些以“gl”开头的函数其实都是OpenggL API 的调用。微软的Visual C++ 对此API有一些有限的文件。
我请你为这个程序增添一个功能(There is one feature that is just begging to be added to this code)。在文件Fractal ExampleView.cpp中,有一个预置的常量DEF_HEIGHT_SCALE (there is a preprocessor constant called DEF_HEIGHT_VALUE)(作者笔误,其实应该是DEF_HEIGHT_SCALE),这个值会被传给位于fractmod.c文件的控制“分形生成”的函数里,以此来达到“缩放”高度值的目的。其实它应该被设定为一个由对话框来控制的变量。请放心地添加这个功能。(意思是,作者认为这个值如果体现在对话框里,直接由用户控制的话会更好更合适,但他的程序没有这么做。因此他希望读者来完成这个功能)。
参考书目
1 、Miller, Gavin S. P., The Definition and Rendering of Terrain Maps. SIGGRAPH 1986 Conference Proceedings (Computer Graphics, Volume 20, Number 4, August 1986).
2 、Voss, Richard D., FRACTALS in NATURE: characterization, measurement, and simulation. SIGGRAPH 1987 course notes #15.
3、 Peitgen, Jurgens, and Saupe, Chaos and Fractals, New Frontiers of Science. Springer-Verlag, 1992.
4 、Fournier, A., Fussell, D., Carpenter, L., Computer Rendering of Stochastic Models, Communications of the ACM, June 1982.
看了上面的文章 热爱游戏创作的你是不是已经开始热血沸腾了呢?是不是迫不及待的想加入游戏团队成为里面的一员呢?
福利来啦~赶快加入腾讯GAD交流群,人满封群!每天分享游戏开发内部干货、教学视频、福利活动、和有相同梦想的人在一起,更有腾讯游戏专家手把手教你做游戏!