基于《波函数坍缩算法》的无限城市程序化随机生成

1.简介

之前在Github上看到《无限城市的随机程序化生成算法》,效果很惊艳,所以花了一些时间对这个项目源码进行了学习,在此备忘和分享学习过程中的一些心得体会.
在这个项目中,提到的算法叫做《波函数坍缩》.光是听到这个名字,就想:"这啥啊?波函数?坍缩?啥跟啥啊?",然后经过查阅,发现是跟量子力学有关的,"量子力学我没学过啊",觉得量子力学会不会太过难以理解,一度差点弃坑...在看到一些这个算法的应用示例以后,终于下定决心想把这个算法原理弄明白,结果发现并不是想象中的那么难懂.希望读者们也不要像我开始一样.因为"量子力学我不会啊","波函数坍缩是啥玩意啊"等等原因就放弃了,其实这个算法很简单的.

1.1 波函数坍缩是什么?

OK,那我们先来简单介绍一下什么是"波函数坍缩"(由于本人对量子力学没有经过专业的学习,所以仅仅是表达个人理解,不对的地方,请不要深究细节).
《双缝干涉》实验
在量子力学中,有一个很著名的实验,叫做《双缝干涉》.实验的过程可以看看这个视频 世界十大经典物理实验之首——电子双缝干涉实验.实验的结果就当没有观测时,光子呈现的是波的性质.当观测时,光子呈现的是粒子的性质.(这是人类在科学实验中正式遭遇灵异的著名试验.)
薛定谔的猫
著名的奥地利物理学家(Nue Mao Kuang Ren)薛定谔提出了一个思想实验,实验的内容可以看这里薛定谔的猫」是指什么?.实验的结论就是当匣子里的猫被观测时,猫的状态要么死要么活,只能在两者之间二选一,而没有观测的话,那么猫的状态既不死也不活(这个状态不符合实际,但是量子力学确实是这么定义它的).

介绍完上面两个著名实验以后,肯定有人提出疑问了,那么它们跟"波函数塌陷"有什么关系呢?关系大着呢.上面两个实验都有表现了一个物质在量子力学中,可能存在多种状态(光子:波,粒子; 猫:死,活,不死不活.)而当物质被观测时,它的状态就被确定了.这个"状态被确定了"的过程,我们就称之为"波函数坍缩".(所以专业术语就是如此,用一个看起来很高大上的名字来形容一个现象或者性质,就算"波函数坍缩"这五个字换成"XXXXX"也不影响理解)

1.2 熵是什么?

介绍完"波函数坍缩"以后,再来谈谈什么是"熵(shang)"吧,熵是热力学的表示物质状态的参量,它的物理意义表示物质的混乱程度,熵越大,说明物质越混乱,熵越小,说明物质越稳定,例如水加热变成雾,这个过程熵值增加(熵增),水降温变成冰,这个过程熵值减少(熵减),把这一概念引入到量子力学中,可以表示当一个物质从叠加态坍缩到某一个状态时,熵减,反之则熵增.
以薛定谔的猫的实验为例,可以这么理解,当没有观察猫的时候,猫处于不死不活叠加态,此时猫死的概率是50%,活的概率也是50%,熵值最大,当打开匣子去观察猫的时候,猫的状态就从不死不活坍缩成死或活的状态,坍缩完以后熵值最小.
既然熵是个度量值,那么就有求熵公式,针对事件发生的概率而言,标准求熵公式(也叫信息熵)为:


标准信息熵公式

其中p(x)表示事件x发生的概率.H(X)就是熵值.

到此为止.量子力学的部分简单介绍完毕,其实这些目前看不懂不明白也没关系,不会影响了解这个随机算法.只是这个算法借用了这些概念和术语而已.

2.原理概述

以日常生活中,以去看电影为例(下面会经常引用到这个例子),假设一家电影院的出售的电影票是没有座位号的,入座由一位入座引导员安排,设总座位个数(即影院容纳人数)为n,从入座引导员的角度来说,如果客人没有要求,那么任何一个人
都可以坐到他想坐的座位上,也就是说,每个人能坐在任一座位的概率都是1/n,而现在我们要加入一些规则了,比如你跟你的朋友一起去,你就需要跟引导员提要求,说你们必须挨着座位坐,那么引导员给你选好了一个座位以后,就会再把你的朋友安排在旁边.从座位的角度来说,你一旦入座,当前你的座位人选概率塌陷成只有你,而旁边的座位人选是你朋友的概率被大大提高.所以类似这样的从混乱变成确定的现象,用一个术语来表示就叫做波函数塌陷.

所以算法的核心原理,就是动态使每个座位的候选对象的范围变得越来越小,直到最后所有的座位都能够选取到合适的对象.(比如每个座位都入座了一个观众(对应坍缩的对象)).而如何动态使候选对象范围变小呢?就是通过约束规则,传播和回溯.
2.1 约束规则
在初始情况下(宇宙一片混沌?)(理论上此时熵值最大),每个座位的可选对象集合都一样,如果没有规则,那随机选取以后的结果,也将会杂乱无章,约束规则的存在,就类似上面提到的"跟引导员提要求,我的朋友要坐在我旁边",各种各样的规则,使得当一个座位被确定(坍缩)了以后,会根据规则影响到其他的座位的可选对象集合.
2.2 传播
当一个座位的人选被确定了(坍缩)了以后,通过规则加上传播,就可以对其他的座位的可选对象集合进行处理.(比如,按上述场景,给引导员一个"我的右手边只能坐妹子"的规则,那么一旦我入座,我右手边的座位可选集合就被处理成只包含妹子了),
在2D世界中,一个物体有4个面(前后左右),而在3D世界中,一个物体有6个面(前后左右上下).每个面对应的一个邻居,所以波函数坍缩的算法传播思路,就可以根据已坍缩位置的邻居进行传播.把规则套进去,对每个邻居的可选集合进行处理.
2.3 回溯
有的时候,当我们的算法在传播过程中,出了错(比如:我给引导员提的规则是"我的右手只能坐我朋友",而我朋友给引导员提的规则是"我不要坐第一排",这样就有可能出现这样的一个情况,一旦引导员把我的座位安排在第一排,我一入座,算法传播以后,我右边的座位可选集合被处理成只能坐我朋友,又因为我朋友"不坐第一排"的规则,最终导致了我右边的座位没有一个人能坐,而我右边没人又跟"我的右手只能坐我朋友"这个规则相悖....这样容易造成冲突矛盾,资源浪费),我们就希望能够把出错的方案排除,然后再回退.重新选一遍.这就是回溯的意义.

3.源码解析

了解上述基本概念和原理以后,结合源代码来看看是如何实现的吧.
3.1 Slot(插槽?座位?)
Slot这个类对象对应了上述例子中的座位.这个类有个很重要的成员对象,就是Modules,它代表了当前Slot所有可选模块的集合**.

//表示一个可以放置模块的插槽,
public class Slot
{
    public Vector3Int Position;     //位置
    public ModuleSet Modules;  //可选的模块集合
    public short[][] ModuleHealth;  //6个方向,可选模块对应的Health,Health为0时,表示不可选取
    private AbstractMap map;        //地图的引用
    public Module Module;       //最终选取的模块
    public bool Collapsed;    //是否已坍缩
    //其他....略
}
10x10排布的Slot

3.2 ModuleProtoType, Module, ModuleSet (模块相关)
ModuleProtoType模块原型,它包含了6个面的信息(前后左右上下),每个面都对应了一个ConnectorID,只有相同的ConnectorID才可以相互连接(这是最基础的约束规则),面的信息分成HorizontalFaceDetails(水平面,前后左右)和VerticalFaceDetails(垂直面,上下),作者通过一个比较Mesh顶点的辅助代码,来自动批量的生成FaceDetails信息.

// 通过计算Mesh的六个面的数据,得到6个方向的ConnectorId,相同的ConnectorId才可以连接,通过Flipped / Rotation来决定要如何连接
public class ModulePrototype : MonoBehaviour
{
    [System.Serializable]
    public abstract class FaceDetails
    { 
        public bool Walkable;    //此面是否允许行走
        public int Connector;    //ConnectorID,相同的ConnectorID才可以相连
        public Fingerprint Fingerprint;  //指纹,通过mesh顶点计算得到vector4数组,用以批量生成ConnectorID
        public ModulePrototype[] ExcludedNeighbours;        //想要排除在外的邻近模块原型        
        //其他....略
    }
    public float Probability = 1.0f;    //被随机选取的概率,求熵时也需要用到
    public bool Spawn = true;       //是否可用
    public FaceDetails[] Faces;                          //6个朝向
}
模块原型,数字代表ConnectorID

Module模块,对应了上述例子中的看电影的观众,在算法做规则判断时候用来的模块,它是由ModuleProtoType(模块原型)绕着Y轴旋转以后得到的.比如:一个ModuleProtoType,分别绕着Y轴旋转0,90,180,270度,就得到4个Module,这样可以使得模块原型的资源得到复用.

public class Module
{
      public string Name;        //名称
      public ModulePrototype Prototype;  //模块原型
      public GameObject Prefab;      //GameObject
      public int Rotation;      //绕Y轴旋转度数,0-0 1-90 2-180 3-270
      public ModuleSet[] PossibleNeighbors;                   //6个方向邻居的可选模块集合
      public Module[][] PossibleNeighborsArray;               //6个方向邻居的可选模块数组
      [HideInInspector]    public int Index;      //索引,与ModuleSet位数组相对应
      public float PLogP;    //概率p*log(p),求熵时需要用到    
      //构造方法
      public Module(GameObject prefab, int rotation, int index)    
      {
          this.Rotation = rotation;
          this.Index = index;
          this.Prefab = prefab;
          this.Prototype = this.Prefab.GetComponent<ModulePrototype>();
          this.Name = this.Prototype.gameObject.name + " R" + rotation;
          this.PLogP = this.Prototype.Probability * Mathf.Log(this.Prototype.Probability);
      }
}
模块会保存到ScriptableObject中,R加数字代表旋转

ModuleSet即模块集合,它使用位数组的方式(什么是位数组,请看这),把预处理好的所有Module都封装到集合中,在Slot中的ModuleSet就表示了当前Slot所有可选的模块了.

public class ModuleSet : ICollection<Module>
{
    [SerializeField]   private long[] data;                    //位数组,1个位对应1模块index,0表示不可选,1表示可选
    private float entropy;          //熵
    private bool entropyOutdated = true;    //熵是否过期(下次需要重新计算)
    public int Count;        //可选模块的总数量
    //其他...略
}

3.3 AbstractMap(地图相关)
AbstractMap这个抽象基类,可以与上述例子的入座引导员做类比,它负责生成和管理了所有地图上的Slot,接下来就看看它是如何执行波函数坍缩的核心算法的.
先获取当前所有需要处理的Slot:

 // 坍缩函数
public void Collapse(Vector3Int size, IEnumerable<Vector3Int> targets, bool showProgress = false)
{
    //其他...略
     this.workArea = new HashSet<Slot>(targets.Select(target => this.GetSlot(target)).Where(slot => slot != null && !slot.Collapsed));  //把所有需要处理的Slot加到workArea中,等待处理
    //其他...略
}

然后计算每个Slot中可选集合的熵,找到熵值最小的Slot

 // 坍缩函数
public void Collapse(Vector3Int size, IEnumerable<Vector3Int> targets, bool showProgress = false)
{
    //其他...略
     this.workArea = new HashSet<Slot>(targets.Select(target => this.GetSlot(target)).Where(slot => slot != null && !slot.Collapsed));
     while (this.workArea.Any())
     {
         float minEntropy = float.PositiveInfinity;
         Slot selected = null;         
         foreach (var slot in workArea)          //遍历Slot,找出熵值最小的那个
         {
             float entropy = slot.Modules.Entropy;       
             if (entropy < minEntropy)
             {
                 selected = slot;
                 minEntropy = entropy;
             }
         }
         try
         {
              selected.CollapseRandom();    //对Slot进行随机坍缩
         }
         catch (CollapseFailedException)      //捕获出错的异常,为了回溯
         {
               //回溯,暂略,下文介绍
         }
    //其他...略
}

对这个Slot进行坍缩,坍缩就是在ModuleSet(可选模块集合)中随机取一个模块(模块的概率越大,越容易被选中)

    public void CollapseRandom()
    {
        if (!this.Modules.Any())    //一个可选模块都没有
        {
            throw new CollapseFailedException(this);
        }
        if (this.Collapsed)
        {
            throw new Exception("Slot is already collapsed.");    //此Slot已坍缩
        }
        float max = this.Modules.Select(module => module.Prototype.Probability).Sum();      //所有可能性累加
        float roll = (float)(InfiniteMap.Random.NextDouble() * max);      //获取Roll值
        float p = 0;
        foreach (var candidate in this.Modules)                 
        {
            p += candidate.Prototype.Probability;
            if (p >= roll)
            {
                this.Collapse(candidate);                           //在可选模块中随机选择一个
                return;
            }
        }
        this.Collapse(this.Modules.First());        //坍缩完毕
    }

坍缩完毕以后,就需要对6个方向的邻居的ModuleSet按规则进行处理.即传播.

    // 坍缩完毕的函数
    public void Collapse(Module module)
    {
        if (this.Collapsed)
        {
            Debug.LogWarning("Trying to collapse already collapsed slot.");
            return;
        }
        this.map.History.Push(new HistoryItem(this));      //加入历史记录,便于回溯
        this.Module = module;
        ModuleSet toRemove = new ModuleSet(this.Modules);    //复制一份当前可选模块的集合
        toRemove.Remove(module);        //在集合中删除当前模块
        this.RemoveModules(toRemove);  //将其他的模块集合从当前集合中删除
        this.map.NotifySlotCollapsed(this);
    }
    //从当前集合中删除一个模块集合,并传播影响邻居
    public void RemoveModules(ModuleSet modulesToRemove, bool recursive = true)
    {
        //其他...略
        for (int d = 0; d < 6; d++)     //6个方向
        {
            int inverseDirection = (d + 3) % 6;     //反方向,也就是指向自己
            var neighborSlot = this.GetNeighbor(d);
            if (neighborSlot == null || neighborSlot.Forgotten)
            {
                continue;
            }
            foreach (var module in modulesToRemove)
            {
                for (int i = 0; i < module.PossibleNeighborsArray[d].Length; i++)
                {
                    var possibleNeighbor = module.PossibleNeighborsArray[d][i];
                    if (neighborSlot.ModuleHealth[inverseDirection][possibleNeighbor.Index] == 1 && neighborSlot.Modules.Contains(possibleNeighbor))  //如果邻居的可选模块是属于待删模块集合中的话,如果Health减完以后变成0,就从邻居的模块集合中删除掉
                    {
                        this.map.RemovalQueue[neighborSlot.Position].Add(possibleNeighbor);    //添加进删除队列
                    }
#if UNITY_EDITOR
                    if (neighborSlot.ModuleHealth[inverseDirection][possibleNeighbor.Index] < 1)
                    {
                        throw new System.InvalidOperationException("ModuleHealth must not be negative. " + this.Position + " d: " + d);
                    }
#endif
                    neighborSlot.ModuleHealth[inverseDirection][possibleNeighbor.Index]--;  //将Health-1
                }
            }
        }
         //其他...略
    }

通过try..catch..来捕获出错的情况,然后回溯,回溯的方式就是将保存在History中最后的两个元素取出,把删除掉的ModuleSet还回去.

//坍缩函数
public void Collapse(Vector3Int size, IEnumerable<Vector3Int> targets, bool showProgress = false)
{
      //其他...略
      try
      {
          selected.CollapseRandom();      //尝试随机坍缩
      }
      catch (CollapseFailedException)        //捕获异常
      {
          this.RemovalQueue.Clear();
          if (this.History.TotalCount > this.backtrackBarrier)
          {
              this.backtrackBarrier = this.History.TotalCount;
              this.backtrackAmount = 2;
          }
          else
          {
                this.backtrackAmount += 4;
          }
          if (this.backtrackAmount > 0)
          {
              Debug.Log(this.History.Count + " Backtracking " + this.backtrackAmount + " steps...");
          }
          this.Undo(this.backtrackAmount);      //回溯backtrackAmount步
      }
      //其他...略
}

这一套流程下来,波函数坍缩的算法就完成了.增加各式各样的约束规则,可以将模块拼成想要的整体.


8x8x6的生成结果

以下是我自己制作一些简单的模块原型,然后套用这个算法制作出来的效果:
仅有五个模块原型:


自定义的5个模块原型.png

生成结果:


生成结果.gif

4.应用案例

以下是其他国外开发者,用《波函数坍缩》的算法,制作出来的案例:

生成城堡.gif
生成管道.gif
生成地球表面.gif
生成岛屿.gif
生成楼房.gif
生成风格化模型.gif
生成类地牢.jpg
机器写的诗句.gif

赛博朋克科幻风城市程序化生成.gif
BadNorth岛屿示意图

5.总结

从以上案例可以看出,通过这个算法,可以做出各式各样的随机地形.以为它是依赖规则,在规则以下做随机坍缩.加以学习和使用,可以做出来很多很惊艳的东西~
5.1 利与弊

好处:
相对于地图拼接的算法( Warcraft3地形拼接算法),这种算法实现出来的效果更灵活更丰富,更能适应各种需求.
坏处:
1.算法复杂度较高而且不好量化,需要计算机自己试错再回溯...(可以通过多线程来计算)
2.暂时没有成熟的制作规范,模块原型的制作难以考虑所有可能的情况.制作进度不好把控.

5.2 模块原型的制作流程?
关于模块原型的制作流程,我个人有个建议可以先使用Unity的建模插件(ProBuilder等)制作简单的原型,然后再让美术同学细化原型.或者美术先制作整体的模型,再通过模型切割工具,把整体切分成多个模块原型.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270