Chapter5 - 当一个勤劳的园丁,来修剪我们美丽的树(I)Canvas绘图 Y型树枝(爱心型) + 控制分支的变化

提醒:本篇承接第三章

让我们说回那颗树

既然树是我们游戏场景的主体之一,首先当然是要来整修一下我们的树,此时我意外发现有个很赞的教学影片:
Fractal Tree Part1
Fractal Tree Part2

给大家参考有别於第三章实作的方式,它画树的方式更单纯,代码相当简短:
https://ithelp.ithome.com.tw/upload/images/20211003/20135197FcLsvyk2Si.png

不过这是因为该影片从碎形(Fractal)开始讨论,意即每个树枝节点都是重复的形状和角度、长度比例,因此无须用物件的形式去储存,只要不断递回画出来即可。

如果不考虑对树做太多随机性的操作,这会是一个很赞的选择。

修饰树的形状

刚好影片中又用到贝兹曲线,我们就来填个坑,当初第三章说要画Y型树枝,结果不了了之,其目的主要在於添加一些扭曲蜿蜒的效果:
https://ithelp.ithome.com.tw/upload/images/20211003/20135197KcU1ocrpTl.png

不过我觉得比较像是爱心尾端的形状xd

在第四章的附录我们有提到,贝兹曲线有四个点(起始点p0、控制点p1、控制点p2、终点p3),在Canvas的绘图中,也曾提过,每次绘制路径都有一个起始点,可以是moveTo(x0, y0)来指定,或者lineTo(x0, y0)一边绘制一边移动到新的起始点,已知起始点的情况下,我们只要给足剩下的条件便能画出贝兹曲线。

因此,贝兹曲线的函数需要另外三个参数:context.bezierCurveTo(x1, y1, x2, y2, x3, y3);

同时上次我们说到,为了让线段能平滑的衔接,控制点会受到上一条曲线(角度)影响,也就是node.father.theta,我们希望让树枝能沿着上一个树枝的方向曲折,最後才回到该树枝的方向node.theta,

for(let N = 1; N < treeNodes.length; N++){
    let node = treeNodes[N];
    let theta1 = node.father.theta / 180 * Math.PI,
        theta2 = node.theta / 180 * Math.PI;
}

该处来自第三章的今天来学习画一颗树 IV

接着就能求出第一个控制点,用树枝本身的长度一半为长度刚刚好:

x1 = x0 + 0.5 * r * Math.cos(theta1)
y1 = y0 - 0.5 * r * Math.sin(theta1)

第二个控制点同理,刚好在原本节点(x0,y0)和(x3,y3)相连的线上:

x2 = x0 + 0.5 * r * Math.cos(theta2)
y2 = y0 - 0.5 * r * Math.sin(theta2)

终点则不变,和当初设定相同:

x3 = x0 + r * Math.cos(theta2)
y3 = y0 - r * Math.sin(theta2)

完整程序码如下:

Tree.prototype.Draw = function(){
    for(let N = 1; N < treeNodes.length; N++){
        let node = treeNodes[N];
        let x = node.father.endX,
            y = node.father.endY,
            r = node.r * Math.pow(node.grow, 1 + 3 * N/treeNodes.length),
            theta1 = node.father.theta / 180 * Math.PI,
            theta2 = node.theta / 180 * Math.PI;
        context.beginPath();
        context.moveTo(x, y);
        context.bezierCurveTo(x + 0.5 * r * Math.cos(theta1),
                              y - 0.5 * r * Math.sin(theta1),
                              x + 0.5 * r * Math.cos(theta2),
                              y - 0.5 * r * Math.sin(theta2),
                              x + r * Math.cos(theta2),
                              y - r * Math.sin(theta2));
        context.lineWidth = 0.5 + Math.pow(r, 1.1)/30;
        context.strokeStyle = 'rgba(220, 200, 200, 1)';
        context.stroke();
    }
}

r = node.r * Math.pow(node.grow, 1 + 3 * N/treeNodes.length)是让树枝依序(递回的顺序)长出来的一个新写法,还没下定论,所以不特别讨论

树的分支

在树枝的建构式,稍作修改,关於到底要递回几次这个问题,当初我们并没有讨论,而是很懒惰的丢了一个times参数进去,就当没事了,不过却很值得思考,因为每多一层递回,树枝的数量都会呈指数增加,太多影响效能,太少则枝叶不够茂盛,必须找个平衡点。

首先思考点可以在於,在视觉上,递回到什麽程度就看不到了呢?眼睛对像素值的判断是有限的,何况有些小细节,不见得会注意的到,那我们就从这里下手,实测之後大约在树枝长度为10px以下停止递回时,算是一个画面表现最佳的时候,那麽就以这为最低基准去设计,不过,为了避免真的递回太多次,我们同样提供一个times参数作为极限值:

let Stick = function(father, shrink_diff, angleOffset, times){
    this.father = father;
    this.r = this.father.r * (shrink_diff);
    this.theta = this.father.theta + angleOffset;
    if(this.r < 20 || times < 0){
        return this;
    }
    // 以下略
    // ......
}

当初我们有给设定一个RATIO,使画布大小为2倍,因此20实际上表示10px
後续针对效能,估计可以给定一个范围,比如20~40,在画面跑不动的时候简化它

接着,在末端的部分通常会特别茂盛,因此我们也定下一个值40(20 x 2),来作为是否进入末端的判断基准,

let shrink = 0.65 + random(0.1);
let diff = random(0.3) - 0.15; // +-0.15
if(this.r > 40)
    this.son = [new Stick(this, (shrink - diff), 30 * (diff + 1), times - 1),
                new Stick(this, (shrink + diff), 30 * (diff - 1), times - 1)];
else
    this.son = [
        new Stick(this, (shrink - diff), 30 * (diff + 1), times - 1),
        new Stick(this, (shrink + diff), 30 * (diff - 1), times - 1),
        new Stick(this, (0.7 + random(0.1)), 30 * ( 0.5 + diff), times - 1),
        new Stick(this, (0.7 - random(0.1)), 30 * (-0.5 - diff), times - 1)];

该处新增了用diff去影响树枝的走向,使其更富有随机性
原版可参考第三章开篇 今天来学习画一颗树 I

这边我将末梢的部分进行着色,就可以看的很清楚
https://ithelp.ithome.com.tw/upload/images/20211003/20135197rWgtDhXH2R.png

每次递回长度的递减比率约为0.5~0.9之间,因此进入20~40的范围时,有些会递回1次,有些会递回2次,便可为末梢带来更多不同变化。

後记

今天有些疲倦,不知道是否太久没运动了(昨天跑去游泳),结果今天躺了一整天ww,把假日都浪费掉了,本来想着可以早点完成这趴,又不可避免的要拆成两篇了


<<:  [Day 18]所以我说那个酱汁呢(後端篇)

>>:  简单了解VR头盔中,重要且相辅相成的Eye tracking 与Foveated Rendering技术 2

Day5 - activity_main.xml 、MainActivity.java

第一次开启专案後 对程序的任何东西都很陌生 映入眼帘的是MainActivity.java的程序码 ...

Day 28:Google Map 显示目前位置

本篇文章同步发表在 HKT 线上教室 部落格,线上影音教学课程已上架至 Udemy 和 Youtu...

Python海关三旬汇率 - Python练习题三

公司需要使用到海关的三旬汇率 海关提供目前汇率与历史三旬汇率 目前汇率(TXT):https://p...

[Day 16] Reactive Programming -Reactor(ConnectableFlux)

图片来源:unsplash fabio 前言 如果你的需求是想要累积集满几个subscriber再...

图的储存结构 - 相邻矩阵 - DAY 20

储存边和弧是否存在 添加权重时的纪录状况 参考来源 大话资料结构 ...