春天来了,来生成一棵独属自己的花树吧!
春天来了,又到了姹紫嫣红开遍的季节,闲来无事尝试复刻写了一个赛博花树生成器,来看看吧。
-
在线体验地址(做了移动适配的):点我访问。
-
效果演示:
实现原理
相信大家肯定一眼就看出来了,主体的花树是使用canvas
生成的,枝丫是随机生成的。下面就一点一点和大家分享一下具体是怎么写的~
基本页面搭建
这部分简单过一下,整体结构非常简单,canvas
元素居中放置,然后设置一下border-radius
为50%
就可以得到一个圆形的canvas
了,但是需要注意的是,这个圆形仅仅是视觉效果,实际上canvas
在使用时还是一个方形的。
canvas相关流程
设置尺寸
需要设置canvas
尺寸,保证其像素尺寸与CSS尺寸一致,同时需要与设备像素比相乘,防止canvas
模糊。
js
// 1. 确保Canvas的像素尺寸与CSS尺寸一致
canvas.width = canvas.clientWidth * devicePixelRatio;
canvas.height = canvas.clientHeight * devicePixelRatio;
设置canvas背景色与底部陆地色块
使用fillStyle
和fillRect
设置即可:
js
// 2. 设置纯色背景与陆地
ctx.fillStyle = "rgb(147,181,173)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgb(37,77,67)";
ctx.fillRect(0, canvas.height - 100, canvas.width, 100);
调整坐标系
canvas
的默认坐标系和绝对定位的坐标系是保持一致的,即:坐标原点在左上角,向右为x轴正方向,向下为y轴正方向。可以看到,我们的树其实是位于整个canvas
元素的正中间的,所以,为了整体的方便,这时需要把坐标轴进行调整,将x轴位置移到底部,y轴位置移到中间,同时正方向进行反向:
通过
canvas
的translate
方法和scale
方法进行实现:
js
// 3. 设置坐标系:原点在Canvas中心底部,Y轴向上 填充陆地
ctx.translate(canvas.width / 2, canvas.height);
ctx.scale(1, -1);
花树生成逻辑
整体是通过递归去实现的,类似二叉树的生成过程,只是在生成时调整了树枝的粗细与长度,越生成越细,越短,分别递归去生成其左右子树枝,直到到达临界条件,递归停止。
初始起点位于调整后坐标系的原点向上位置(位于陆地之上),角度为90°,即垂直生成树干。整体的伪代码如下:
js
// 花树参数
const flowerColors = ["#ff4400", "#ffffff", "#ff6699", "#ffecf1", "#ff8cb0"]; // 花朵颜色数组,随机选择
const initialBranchLength = 120; // 初始枝条长度
const initialBranchThick = 20; // 初始枝条粗细
const endBranchThick = 2; // 递归结束条件:枝条粗细
const possibleEndBranchThick = 8; // 防止树枝生成的太规整茂密,可以进行随机剪枝的临界条件
const branchColor = "rgb(48,41,30)"; // 枝条颜色
/**
* 画树
* @param {number[]} v0 线段起点
* @param {number} length 线段长度
* @param {number} thick 线段粗细
* @param {number} deg 角度
* @param {string} branchColor 树枝颜色
* @param {string[]} flowerColors 花对应的颜色数组
* @param {number} generateTimes 递归次数,防止初始就进行剪枝,保底生成一下枝丫
*/
function drawBranch(
v0,
length,
thick,
deg,
branchColor,
flowerColors,
generateTimes
) {
if (终止条件(可以是树枝粗细小于某个值,或是长短小于某个值)) {
生成花朵;
return;
}
计算线段终点,绘制线段;
// 递归生成左子树
drawBranch(
v1,
length * (Math.random() * 0.1 + 0.75), // 树枝长度随机减小
thick * (Math.random() * 0.1 + 0.75), // 树枝粗细随机减小
deg + (Math.random() * 30 + 5), // 角度增大,即向左偏移
branchColor,
flowerColors,
generateTimes + 1
);
// 递归生成右子树
drawBranch(
v1,
length * (Math.random() * 0.1 + 0.75), // 树枝长度随机减小
thick * (Math.random() * 0.1 + 0.75), // 树枝粗细随机减小
deg - (Math.random() * 30 + 5), // 角度减小,即向右偏移
branchColor,
flowerColors,
generateTimes + 1
);
}
drawBranch(// 初始调用
[0, 50],
initialBranchLength,
initialBranchThick,
90,
branchColor,
flowerColors,
1
);
树枝的本质其实是一段线段,生成树枝其实就是在上一条线段的终点位置,以这个终点作为新线段的起点,随机幅度调整线段的粗细和长短,去生成新的线段。下面是计算线段终点,绘制线段部分的代码:
js
ctx.beginPath();
ctx.moveTo(...v0); // 画笔移动到起点
const v1 = [ // 三角函数计算终点xy坐标
v0[0] + length * Math.cos((deg * Math.PI) / 180),
v0[1] + length * Math.sin((deg * Math.PI) / 180),
];
ctx.lineTo(...v1);
ctx.lineWidth = thick;
ctx.lineCap = "round";
ctx.strokeStyle = branchColor;
ctx.stroke();
由于是递归调用,需要给出终止条件,也就是树梢。在树梢可以绘制花朵,花朵绘制很简单,这里是采用了绘制一个纯色的圆形,圆形颜色从给出的颜色数组中进行配置:
js
if (thick < endBranchThick) {
ctx.beginPath();
ctx.arc(v0[0], v0[1], 5, 0, Math.PI * 2);
ctx.fillStyle =
flowerColors[Math.floor(Math.random() * flowerColors.length)];
ctx.fill();
return;
}
同时,为了防止最终生成的树枝太过于规整和茂密,又给出了可以进行随机剪枝的阈值,也是线条的粗细来进行处理:
js
if (thick < possibleEndBranchThick && Math.random() < 0.4) return;
也就是当线条粗细缩减到endBranchThick
(此处设置为2)后,不再生成新枝条,生成结束,缩减到possibleEndBranchThick
(此处设置为8)后,有40%的概率会提前中断该分支的剩余枝条的生成。
同时,后面进行递归生成左右子树时,也设置了相关的概率,提前中断子树的生成,但是为了保证初始生成的顺利,引入迭代次数进行了限制:
js
if (generateTimes <= 2 || Math.random() > 0.05) {
drawBranch(
v1,
length * (Math.random() * 0.1 + 0.75),
thick * (Math.random() * 0.1 + 0.75),
deg + (Math.random() * 30 + 5),
branchColor,
flowerColors,
generateTimes + 1
);
}
if (generateTimes <= 2 || Math.random() > 0.05) {
drawBranch(
v1,
length * (Math.random() * 0.1 + 0.75),
thick * (Math.random() * 0.1 + 0.75),
deg - (Math.random() * 30 + 5),
branchColor,
flowerColors,
generateTimes + 1
);
}
完整代码如下:
js
// 花树参数
const flowerColors = ["#ff4400", "#ffffff", "#ff6699", "#ffecf1", "#ff8cb0"];
const initialBranchLength = 120;
const initialBranchThick = 20;
const endBranchThick = 2;
const possibleEndBranchThick = 8;
const branchColor = "rgb(48,41,30)";
/**
* 画树
* @param {number[]} v0 线段起点
* @param {number} length 线段长度
* @param {number} thick 线段粗细
* @param {number} deg 角度
* @param {string} branchColor 树枝颜色
* @param {string[]} flowerColors 花对应的颜色数组
* @param {number} generateTimes 递归次数,保底生成一下树枝丫
*/
function drawBranch(
v0,
length,
thick,
deg,
branchColor,
flowerColors,
generateTimes
) {
if (thick < endBranchThick) {
ctx.beginPath();
ctx.arc(v0[0], v0[1], 5, 0, Math.PI * 2);
ctx.fillStyle =
flowerColors[Math.floor(Math.random() * flowerColors.length)];
ctx.fill();
return;
}
if (thick < possibleEndBranchThick && Math.random() < 0.4) return;
ctx.beginPath();
ctx.moveTo(...v0);
const v1 = [
v0[0] + length * Math.cos((deg * Math.PI) / 180),
v0[1] + length * Math.sin((deg * Math.PI) / 180),
];
ctx.lineTo(...v1);
ctx.lineWidth = thick;
ctx.lineCap = "round";
ctx.strokeStyle = branchColor;
ctx.stroke();
if (generateTimes <= 2 || Math.random() > 0.05) {
drawBranch(
v1,
length * (Math.random() * 0.1 + 0.75),
thick * (Math.random() * 0.1 + 0.75),
deg + (Math.random() * 30 + 5),
branchColor,
flowerColors,
generateTimes + 1
);
}
if (generateTimes <= 2 || Math.random() > 0.05) {
drawBranch(
v1,
length * (Math.random() * 0.1 + 0.75),
thick * (Math.random() * 0.1 + 0.75),
deg - (Math.random() * 30 + 5),
branchColor,
flowerColors,
generateTimes + 1
);
}
}
重绘制&保存图像逻辑
重绘制比较简单,从头走一遍canvas
的配置逻辑就好了:
js
regenerateButton.addEventListener("click", () => {
// 重置坐标系并重新绘制
ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置所有变换
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgb(147,181,173)";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgb(37,77,67)";
ctx.fillRect(0, canvas.height - 100, canvas.width, 100);
ctx.translate(canvas.width / 2, canvas.height);
ctx.scale(1, -1);
drawBranch(
[0, 50],
initialBranchLength,
initialBranchThick,
90,
branchColor,
flowerColors,
1
);
});
保存图像这里,因为最终想要将图像和视觉效果一致,也就是图像需要是一个圆形的,又单独裁切了一下:
js
saveButton.addEventListener("click", () => {
// 创建一个临时的Canvas元素用于处理圆形裁剪
var tempCanvas = document.createElement('canvas');
var tempCtx = tempCanvas.getContext('2d');
var centerX = canvas.width / 2;
var centerY = canvas.height / 2;
var radius = Math.min(centerX, centerY); // 圆的半径
// 设置临时Canvas的尺寸与原始Canvas相同
tempCanvas.width = canvas.width;
tempCanvas.height = canvas.height;
// 在临时Canvas上开始圆形裁剪区域
tempCtx.beginPath();
tempCtx.arc(centerX, centerY, radius, 0, Math.PI * 2);
tempCtx.clip();
// 将原始Canvas的内容绘制到临时Canvas上
tempCtx.drawImage(canvas, 0, 0);
// 使用临时Canvas的数据URL来下载图片
const dataURL = tempCanvas.toDataURL("image/png");
const link = document.createElement("a");
link.style.display = "none";
link.href = dataURL;
link.download = "your-flower-tree.png";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
这样,完成了整体的效果~