基础--分形

GIF1

目录

[TOC]

写在前面

之前一直在考虑要不要写这篇博客,因为全篇实在没有什么难点,只是做了点有趣的事情,如果读者对递归有简单的了解又想节省时间的话完全可以离开了,这篇文章会浪费您的时间。

劝退的话说完了,呼~~接下来说说本篇博客都干了些什么:主要是为后续要出的地图生成打下点基础。递归是程序中非常好的一个思想,很多表面看起来复杂但细节或结构却充满重复的东西,用递归实现再好不过了。

分形是数学中的一个概念,本篇生成的分形结构并不是严格意义上的分形,但是看起来却有那么点意思。全篇难度很低,放心阅读。

在PC前的各位读者,建议下载MD文件阅读,观感更好。若有图片无法显示,请开启网络。欢迎转载,只需注明转载来源地址即可。MD文件及工程Package下载地址(提取码:82kk)

1.准备工作

首先来一个好一点的工程文件夹结构,我们可能不会都用到,但却是一个做小Demo的好习惯。

picture1

在场景中创建一个空物体,取名为”Fractal”,它将成为我们与Unity交互的主入口。

创建脚本”Fractal”,并将其拖拽到空物体”Fractal”上。

2.生成物体策略

要生成一个复杂结构,我们得从简单的开始。我们不会使用预制体来生成,我们将直接通过脚本来完成这一操作。

准备工作中创建的挂载有”Fractal”脚本的空物体就是我们创建整个分形结构的基础。

物体要能绘制出形态,需要MeshFilter(网格过滤器)和MeshRenderer(网格渲染器)这两个组件。我们在Start中为其添加这两个组件。

        private void Start()
        {
            gameObject.AddComponent<MeshFilter>().mesh;
            gameObject.AddComponent<MeshRenderer>();
        }

接下来我们要指定我们想要绘制的物体的形状和颜色:形状需要在MeshFilter中添加Mesh;颜色需要在MeshRenderer中添加Material。

我们定义两个公共变量,以便在Unity的检视面板中直接赋予我们想要的Mesh和Material。

        public Mesh Mesh;
        public Material Material;

然后创建一个Material,取名为”FractalMat”,为其指定Shader:”Legacy/Shaders/Specular”[^这是什么Shader?]。

picture2

Mesh我们不需要创建,Unity自带的默认Mesh有如下几个,我们先选择Cube作为我们分形基础的Mesh。

picture3

在检视面板中赋值Mesh和Material。[^检视面板中为什么没有MeshFilter和MeshRenderer组件?]

picture4

我们从外部告诉了我们的CSharp脚本我们需要的Mesh(形状)、Material(颜色),现在,我们要在代码中告诉Start方法,在运行时我们想赋予”Fractal”什么样的形状和颜色。

        private void Start()
        {
            gameObject.AddComponent<MeshFilter>().mesh = Mesh;
            gameObject.AddComponent<MeshRenderer>().material = Material;
        }

我们完成了分形基础的生成,接下来就要发挥递归的作用了。

3.递归生成子物体

我们在写普通的递归算法的时候都知道,递归是需要一个递归出口[^什么是递归出口?]的,如果程序无休止的递归运行下去,会迅速将计算机的内存资源消耗殆尽,最终会导致堆栈溢出异常或崩溃。我们既然要使用递归的方式生成子物体,那么定义一个递归出口是很有必要的,此处我们定义一个递归深度,当物体生成超过这一深度的时候,停止递归生成子物体。

        /// <summary>
        /// 最大深度
        /// </summary>
        public int MaxDepth;
        /// <summary>
        /// 当前分形的深度
        /// </summary>
        private int mDepth;

父子物体之间应该有一定的关系,这样才能形成一个具有自相似性质的分形结构。我们定义如下关系。

        /// <summary>
        /// 孩子的大小(相对于父亲的大小)
        /// </summary>
        public float ChildScale;

接下来我们要写创建子物体的方法了,这个是全篇的第一个核心函数。我们可以使用协程函数来完成这一操作,因为观看分形结构的逐步生成是很享受的一件事,我们可以为创建子物体添加点随机性,但我们暂时不这么做,我们将所有的随机性都放到后面完成,我们先来看一看一个”规矩”的分形结构的生成。

        private IEnumerator CreateChild()
        {
            for (int i = 0; i < mChildDirections.Length; i++)
            {
                yield return new WaitForSeconds(0.3f);
                new GameObject("Fractal Child").AddComponent<Fractal>();
            }
        }

此时程序并不会像我们预想的方向运行,没有递归生成我们的分形结构。这是因为我们还没有将必要的信息由父亲传递给孩子。

我们创建一个初始化的方法,在此方法中完成父子信息的传递。

孩子的大小应该是在相对于父亲的坐标空间中进行设置,transform.localScale = Vector3.one * ChildScale;读者需要理解一下,这个坐标空间的转换过程。

设置完孩子大小后,就需要设置孩子的位置,同样是相对于父亲的位置:transform.localPosition = Vector3.up * (0.5f + 0.5f * ChildScale);,我们先设置一个竖直向上方向的孩子的位置,当然我们还有其他5个方向,毕竟一个Cube是有上下左右前后六个方向的。我们要对括号内的式子(0.5f + 0.5f * ChildScale)做一点说明:第一个0.5f是使得孩子的中心点能够移动到父亲指定方向的边缘上,此时孩子有一半是叠加在父亲内部的,所以后一半的0.5f * ChildScale是将孩子从父亲内部完全剥离出来,此时孩子是精确贴合在父亲指定方向的表面处。

最后还要设置孩子的旋转,孩子的方向虽然是竖直向上的,但应该是相对于父亲空间竖直向上,而不是世界坐标系,所以我们需要做一点调整:transform.localRotation = Quaternion.identity;

        private void Init(Fractal parentFractal)
        {
            //信息传递:
            MaxDepth = parentFractal.MaxDepth;//最大深度
            mDepth = parentFractal.mDepth + 1;//当前深度
            Mesh = parentFractal.Mesh;
            Material = parentFractal.Material;
            ChildScale = parentFractal.ChildScale;//孩子大小(相对于父亲的大小)
            //初始化设置:
            transform.parent = parentFractal.transform;
            transform.localScale = Vector3.one * ChildScale;//设置大小
            transform.localPosition = Vector3.up * (0.5f + 0.5f * ChildScale);//设置相对于父亲的位置
            transform.localRotation = Quaternion.identity;//设置旋转
        }

上面我们提到了一个Cube是有六个不同的方向的,我们需要在各个方向上生成子物体,而在设置相对位置和旋转的时候也是依赖于方向的。我们不需要为六个方向的生成重复写六次上述代码,我们可以增加两个数组,一个包含方向,一个包含旋转,以此来复用代码。

        /// <summary>
        /// 孩子的伸展方向
        /// </summary>
        private static Vector3[] mChildDirections =
        {
            Vector3.up,
            Vector3.down,
            Vector3.left,
            Vector3.right,
            Vector3.forward,
            Vector3.back,
        };
        /// <summary>
        /// 孩子的旋转方向
        /// </summary>
        private static Quaternion[] mOrientations =
        {
            Quaternion.identity,
            Quaternion.Euler(0,0,180),
            Quaternion.Euler(0,0,90),
            Quaternion.Euler(0,0,-90),
            Quaternion.Euler(90,0,0),
            Quaternion.Euler(-90,0,0),
        };

修改初始化孩子的代码,同时我们还需要增加一个孩子的索引来串联我们两个数组。

        private void Init(Fractal parentFractal, int childIndex)
        {
            //信息传递:
            MaxDepth = parentFractal.MaxDepth;//最大深度
            mDepth = parentFractal.mDepth + 1;//当前深度
            Mesh = parentFractal.Mesh;
            Material = parentFractal.Material;
            ChildScale = parentFractal.ChildScale;//孩子大小(相对于父亲的大小)
            //初始化设置:
            transform.parent = parentFractal.transform;
            transform.localScale = Vector3.one * ChildScale;//设置大小
            transform.localPosition = mChildDirections[childIndex] * (0.5f + 0.5f * ChildScale);//设置相对于父亲的位置
            transform.localRotation = mOrientations[childIndex];//设置旋转
        }

修改创造孩子部分的代码,添加初始化孩子的操作,在初始化的时候,将父亲(也就是当前的自身)和索引传递进去。

        private IEnumerator CreateChild()
        {
            for (int i = 0; i < mChildDirections.Length; i++)
            {
                yield return new WaitForSeconds(0.3f);
                new GameObject("Fractal Child").AddComponent<Fractal>().Init(this, i);
            }
        }

我们在Start方法中开启此创造孩子的协程函数,注意递归出口。

        private void Start()
        {
            gameObject.AddComponent<MeshFilter>().mesh = Mesh;
            gameObject.AddComponent<MeshRenderer>().material = Material;
            if (mDepth < MaxDepth)
            {
                StartCoroutine(CreateChild());
            }
        }

4.分形初现

回到Unity的编辑界面,在检视面板中,我们设置一下递归深度为4[^递归深度设置多少合适?]和孩子大小为0.5。

picture5

点击运行,观察一个”规矩”的分形结构的生成过程。

GIF2

5.更多的随机性

简单的分形结构我们已经生成完成了,现在我们可以为其添加更多的随机性,增加点活力与生机,我们不希望它看起来太”程序化”。

a.随机的生成时间

先从最简单的开始吧,增加子物体随机的生成时间,我们只需要修改一下CreateChild方法。让其生成时间在[0.1f,0.5f)之间随机。

        private IEnumerator CreateChild()
        {
            for (int i = 0; i < mChildDirections.Length; i++)
            {
                yield return new WaitForSeconds(Random.Range(0.1f, 0.5f));
                new GameObject("Fractal Child").AddComponent<Fractal>().Init(this, i);
            }
        }

得到的分形结构生成:

GIF3

b.随机的颜色

白色的Cube实在是太单调了,我们来丰富一下颜色。我们为每层深度都创建一个共用的Material,这样一来每层都会拥有各自的颜色,色彩会丰富许多。

我们使用一个二维数组来存储Material[^为什么使用二维数组,而不是一维数组?]。

        /// <summary>
        /// 各层深度的Material数组
        /// </summary>
        private Material[,] Materials;

我们需要对数组进行初始化,向其中注入各种颜色。我们是根据层深进行颜色的插值,使得由内到外、由父到子的颜色是一个渐变的过程。插值参数选用float t = (float)i / MaxDepth;非常好的关联了层深信息。此处属于线性插值,也就是说颜色的变化是线性的,是一个均匀的变化。同样的我们还可以尝试其他的颜色变化速度,例如平方型–由慢到快的加速型颜色变化,层越深颜色越深。

        private void InitMaterials()
        {
            Materials = new Material[MaxDepth + 1, 3];
            //设置中间层深的颜色
            for (int i = 0; i <= MaxDepth; i++)
            {
                float t = (float)i / MaxDepth;
                Materials[i, 0] = new Material(Material);
                Materials[i, 0].color = Color.Lerp(Color.white, Color.red, t);
                Materials[i, 1] = new Material(Material);
                Materials[i, 1].color = Color.Lerp(Color.white, Color.green, t);
                Materials[i, 2] = new Material(Material);
                Materials[i, 2].color = Color.Lerp(Color.white, Color.blue, t);
            }
            //设置最大深度的颜色
            Materials[MaxDepth, 0].color = Color.magenta;
            Materials[MaxDepth, 1].color = Color.cyan;
            Materials[MaxDepth, 2].color = Color.gray;
        }

相同深度的层拥有三种随机的颜色,这样看起来就好多了。我们还需要修改初始化孩子的函数,将Material数组传递给孩子,以保证所有的物体都使用的相同的一个Material数组。

        private void Init(Fractal parentFractal, int childIndex)
        {
            //信息传递:
            Materials = parentFractal.Materials;//Material数组
            ......
            //Material = parentFractal.Material;
            ......
        }

接下来我们要在Start中初始化这个Material数组,并从中随机取出颜色赋值给自身。

        private void Start()
        {
            if (Materials == null)
            {
                InitMaterials();
            }
            ......
            gameObject.AddComponent<MeshRenderer>().material = Materials[mDepth, Random.Range(0, 3)];
            //gameObject.AddComponent<MeshRenderer>().material = Material;
            ......
        }

得到的分形结构生成:

GIF4

如果觉得起始的颜色变化太剧烈,可以将颜色差值参数变为平方型t = t * t;

        private void InitMaterials()
        {
            ......
            //设置中间层深的颜色
            for (int i = 0; i <= MaxDepth; i++)
            {
                float t = (float)i / MaxDepth;
                t = t * t;
                ......
            }
            ......
        }

得到的分形结构生成:

GIF5

c.随机的形状

颜色已经拥有随机性了,我们可以为形状也添加一点随机性,同颜色方法类似,我们也需要一个Mesh数组来存放我们希望随机的形状。建议随机形状设置为Cube和Sphere即可,圆柱和椭圆等图形生成出来的观感不太好。

        /// <summary>
        /// 网格数组
        /// </summary>
        public Mesh[] Meshes;

网格数组的初始化比较简单,由于设置为公共变量,所以可以直接在Unity的编辑器中进行设置,此处设置的Cube:Sphere=1:2。

picture6

在初始化孩子的方法中将Mesh数组传递给孩子。

        private void Init(Fractal parentFractal, int childIndex)
        {
            //信息传递:
            ......
            Meshes = parentFractal.Meshes;//网格数组
            //Mesh = parentFractal.Mesh;
            ......
        }

在Start函数中随机取出Mesh赋值给自身。

        private void Start()
        {
            ......
            gameObject.AddComponent<MeshFilter>().mesh = Meshes[Random.Range(0, 3)];//Cube : Sphere = 1 : 2
            //gameObject.AddComponent<MeshRenderer>().material = Materials[mDepth, Random.Range(0, 3)];
            ......
        }

得到的分形结构生成:

GIF6

d.随机的生成概率

孩子不一定得全部生成,我们可以增加一个生成概率,这样看起来就更具随机性。

增加一个可调整的公共变量。

        /// <summary>
        /// 生成孩子的概率
        /// </summary>
        [Range(0f, 1f)]
        public float SpawnProbability;

修改CreateChild函数。

        private IEnumerator CreateChild()
        {
            for (int i = 0; i < mChildDirections.Length; i++)
            {
                //概率生成孩子
                if (Random.value <= SpawnProbability)
                {
                    yield return new WaitForSeconds(Random.Range(0.1f, 0.5f));
                    new GameObject("Fractal Child").AddComponent<Fractal>().Init(this, i);
                }
            }
        }

在检视面板中设置SpawnProbability=0.8。

picture7

得到的分形结构生成:

GIF7

e.随机旋转

我们还可以增加一点运动的元素。比如增加一个绕着自身坐标系的y轴的随机旋转。

定义一个公共变量

        /// <summary>
        /// 最大旋转速度
        /// </summary>
        public float MaxRotateSpeed;
        /// <summary>
        /// 旋转速度
        /// </summary>
        private float mRotateSpeed;

在Start中计算随机的旋转速度。

        private void Start()
        {
            ......
            //gameObject.AddComponent<MeshRenderer>().material = Materials[mDepth, Random.Range(0, 3)];
            //计算随机旋转速度
            mRotateSpeed = Random.Range(-MaxRotateSpeed, MaxRotateSpeed);
            ......
        }

在Update中每帧更新旋转。

        private void Update()
        {
            transform.Rotate(0, mRotateSpeed * Time.deltaTime, 0);
        }

回到检视面板中设置最大旋转速度为50度。

picture8

得到的分形结构生成:

GIF8

f.随机的绕x轴的扭曲

物体规整的在正上方生成显得有些呆板,我们可以给它一点初始的旋转。

        /// <summary>
        /// x轴上的最大扭曲(旋转)
        /// </summary>
        public float MaxTwist;

修改Start方法。

        private void Start()
        {
            ......
            //计算随机旋转速度
            mRotateSpeed = Random.Range(-MaxRotateSpeed, MaxRotateSpeed);
            //扭曲(x轴)
            transform.Rotate(Random.Range(-MaxTwist, MaxTwist), 0, 0);
            ......
        }

在检视面板中设置MaxTwist=20。

picture9

得到的分形结构生成:

GIF9

我们可以观察到子物体向下的分支覆盖到了父物体中,为什么会如此呢?原因在于我们定义的子物体生成方向有六个,覆盖了所有方向,而父子之间是有一个方向相连接的,所以必然会产生覆盖。解决的方法就是去掉向下的方向。

修改两个数组:

        /// <summary>
        /// 孩子的伸展方向
        /// </summary>
        private static Vector3[] mChildDirections =
        {
            Vector3.up,
            //Vector3.down,
            Vector3.left,
            Vector3.right,
            Vector3.forward,
            Vector3.back,
        };
        /// <summary>
        /// 孩子的旋转方向
        /// </summary>
        private static Quaternion[] mOrientations =
        {
            Quaternion.identity,
            //Quaternion.Euler(0,0,180),
            Quaternion.Euler(0,0,90),
            Quaternion.Euler(0,0,-90),
            Quaternion.Euler(90,0,0),
            Quaternion.Euler(-90,0,0),
        };

由于我们设置了孩子生成概率,所以生成的物体数量会有所减少,我们可以适当增加层深来增加更多分形细节。

得到的分形结构生成:

GIF1

写在最后

本篇只是分形初探,更多深入内容如果读者有兴趣可以自行探索学习。本篇内容到此就结束了,感谢阅读!

Reference

Constructing a Fractal Details Through Recursion