前端视觉交互——TreesGenerator2D
世界Skill
利用约束参数随机通过点击生成一颗颗抽象化的树。
这个效果是我在去年(2017)的the best of codepen发现的,觉得是很有意思,非常具有艺术感,于是学了下原理并在这里实现了一下。
code, demo
原理
此效果原理稍微有点复杂,需要分为“树的生成”以及“树的绘制”两部分,下面让我们来一步一步分析:
从一棵树开始
这个效果是可以支持多棵树同时生长的,但其基础还是一颗颗独立的树,所以为了弄清整个的原理,让我们先从一棵树开始。
树的抽象
为了绘制一棵树,我们首先要构建一个合适的对象来描述它。根据在现实世界的观察,不难得知一棵树最重要由三部分构成——“干”、“枝”、“叶”/“花”,所以再要构建的Tree
对象中,理应有三个成员来描述它们。但考虑到在在一棵树的快进的生命周期中,其主干和分枝的生长应该是可以并行进行的,所以本质上它们是一个东西,而叶子不同,仍然是独立的,所以在Tree
中我们只需要points
和leaves
两个成员来描述,前一个描述枝干的点集,后一个描述叶子,那么为什么是“点集”而不是别的呢,接下来就让我们分析一下。
树的绘制
有了树的抽象结构之后,要考虑的就是这样一个问题——假设我们已经有了一颗由以上的树,如何来描述其枝干和叶子,使其可以被最方便得绘制?考虑到绘制的媒介是canvas,我们能使用的工具也无非以下几个:
- rect
- image
- arc
- ellipse
- line
rect和line对于一棵树的绘制而言太生硬,image不予考虑,剩下的也就只有arc和ellipse了。这二者其实没有本质上的区别,arc绘制的是圆弧、ellipse绘制的是椭圆,前者是后者的一个子集。相对而言,arc控制起来更为简单,所以在这里我们选择了arc——等等,有这么简单吗?
让我们仔细想想,arc绘制的是圆弧,设定一定的参数后便可以画出一个圆,而出去本身确实可以用圆来描述的抽象的叶子,枝干如何用圆来描述呢?显然我们不可能去真的绘制一个个很小的点并将其密集拼成像素化的树,这无论是性能还是效果都不会好。那么究竟应该如何去做?
答案其实很简单——拼起来即可。不错,你自己去尝试一下便可知,适当调整几个圆的绘制位置、半径、颜色和透明度,将它们叠在一起,你变可以得到一个类似于真实世界中的树干——它并不那么规整,但却乱得很合适,比如:
js
ctx.fillStyle = 'hsla(208, 80%, 91.5%, 1)';
ctx.beginPath();
ctx.arc(400, 560, 16, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(400, 555, 15, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(400, 550, 14, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(400, 545, 13, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(400, 540, 13, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(400, 535, 11, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(400, 530, 12, 0, Math.PI * 2);
ctx.fill();
当然,这里的位置、半径、颜色和透明度等约束参数如何确定也是一门技术活,这个后面会讲到。
如此一来,我们便可以通过一个点集来绘制出一棵树,接下来的重点就是如何生成这些点集,换言之,就是“如何让一棵树从某个位置的一个点生长出来”。
生长
树是从一点生长出来的,也就是说,在初始化树的时候我会传给它一个坐标,就像是埋下了一个种子,它以这个种子为基点长出来,并且在合适的地方生长和分叉。这就需要一套算法去计算这种“生长”结果,显然,这是一个和历史状态相关的函数,我们可以将此函数描述为:
points[n] = f(points[n - 1])
tree = points + leaves
其中points
即为枝干的点集,那么我们如何来描述一个点呢?其实很简单,如前面所述,树的绘制是以点为基本单位的,所以这个点要能描述一个圆,所以其基本的属性也就是x
、y
、color
、radius
、opacity
。也就是说,以上这个函数的本质,就是通过前一个点的这些属性,生成下一个点的这些属性。
有了这个思路,接下来就这个函数的实现了,这个实现需要一些数学知识,比如PerlinNoise。不过我们先不管这个,先回顾并理顺一下树生成的主逻辑:
- 首先确定一个种子的位置,生成种子,也就是第一个点。
- 然后不断调用函数f,生成下一个点、下下个点......
- 树的生长是有其极限的,这个极限应该是我们可以定义的。
- 生长到极限以后长出叶子。
可见,除去函数f,我们首先要定义一个树的生长极限并强制让它在这个时候停止生长。这个实现起来比较简单,只要在初始化的时候记录一个age
,之后在点生成将其传入给点,保证每个点的age
独立计算,并在生成新点的时候的时候不断检查这个age
,看下一步是生成新的点还是叶子,到极限后在去遍历leaves
数组绘制叶子,这个后面再说。
现在让我们回到函数f本身,看看它是怎么运作的。
生成函数
生成函数的核心是age
、variance
这两个点的属性,第一个前面说过了,是树的年龄,而一个则是分歧系数。其中年龄主要用于和PerlinNoise
以及之前点的x
和y
组合计算出dirX
和dirY
,而variance
则负责和degrees
组合计算出randX
和randY
,它们一起组合生成下一个点的x
和y
:
```js const reduce = 0.01; const n = (noise.at((point.x + point.age) * reduce, (point.y + point.age) * reduce) - 0.5) * 4 * Math.PI; const mag = noise.at((point.y + point.age) * reduce, (point.x + point.age) * reduce); const dirX = Math.cos(n) * mag; const dirY = Math.sin(n) * mag;
const diff = variance * point.opacity; const degrees = point.degrees + (-diff + Math.random() * diff * 2);
const randX = Math.cos(radians(degrees)) * Tree.DRAW_DISTANCE; const randY = Math.sin(radians(degrees)) * Tree.DRAW_DISTANCE;
const x = point.x + dirX + randX; const y = point.y + dirY + randY; ```
我们不去细究PerlinNoise
的细节,它本质上就是为了生成微小随机噪声,为树的生长提供一定接近真实的随机性,而variance
这个分歧系数则是为了提供一个大幅度改变树生长朝向的能力,也就是为树提供产生分支的能力,其具体是通过修改degrees
这个角度来实现的。
进一步研究其细节,会发现在树的整个生命周期中,主干一定会一直生长,而分枝是否会出现则是依据一个随机数是否在某一个范围内,同时随着年龄的增长,点的直径会越来越小,这就是最终生成的一颗一颗树是那个样子的理由。
叶子
当树生长到极限并满足一定的随机数之后,便会生成叶子,其实叶子和枝干的构成基本一致,只不过其受到的约束没有这么强,只要在其依附的分支周边一定范围内、并满足一定范围的大小即可。
这里程序设定了LEAF_DISTANCE
和LEAFE_SIZE
两个静态变量来约束叶子的范围和大小。
生成了叶子后,绘制也和枝干一样,用同样的方法将其画到对应区域即可。
到整片森林
完成了一棵树的绘制,要考虑的便是一个森林中许多树的并行生长了,所谓并行生长,就是说一颗树的生长不能阻塞其它树的生长,这个分解开来,便是并行计算与并行绘制。
为了维护多棵树,我们需要一个trees
数组。这个数组负责在生成一棵树的时候将其入队,并在其绘制完成时将其剔除,不但如此,它还要管理每一棵树的“生长 -> 绘制”流程。这就要求树类Tree
本身有一个生命周期的机制,来维护和管理其自身的初始化、更新、绘制和释放。
这个机制做起来并不困难,首先我设置了两个标记属性generated
和drawn
,分别表示树的生成状态和绘制状态。同时我也设计了两个方法update
和draw
分别用于更新计算和绘制。这两个生命周期函数在外部RAF触发的绘制循坏下不断执行,同时利用并更新两个标志位,便可以完成一棵树的整体的管理。
而得益于这种机制,树的并行绘制也水到渠成——只需要在每一帧轮询trees
里的每一个对象,调用其对应的生命周期即可。当然,这也是绝大多数库包括游戏引擎的流行做法。