本教程主要教大家通过使用p5js
等工具,来绘制出动态生成的花束,步骤如下。
- 背景绘制
- 花瓣绘制
- 花朵绘制
- 树枝绘制
下面,我将会逐一为大家进行详细地讲解,请做好笔记哦~
一、背景绘制
首先是背景绘制。
将画布看作是640x640
的网格,在每个网格中绘制直径为1的圆形,而每个圆形填充颜色的透明度与该位置y坐标值成正相关,即y值越大,透明度越大。
将画面中间裁去一个圆圈,在位于圆圈里面的点的位置绘制直径为1的圆形,每个圆形填充颜色的透明度与该位置y坐标值成负相关,即y值越大,透明度越小。
同时为了让渐变看的更自然,对每个位置填充颜色的透明度加上了randomGaussian(0,10)
的高斯噪声。
代码如下。
scss
function setup() {
createCanvas(640,640);
background(255);
}
function draw(){
push();
var rc = color(random(255),random(255),random(255));
for(var i = 0;i < width;i++){
for(var j = 0;j < height;j++){
// 绘制背景
if (dist(i,j,width/2,height/2)>150) {
var ra = constrain(map(j,0,height,-100,150),0,150)+randomGaussian(0,10);
rc.setAlpha(ra);
fill(rc);
noStroke();
ellipse(i,j,1,1);
}else {
// 绘制玻璃球
var ra = constrain(map(j,0,height,100,-50),0,150)+randomGaussian(0,10);
rc.setAlpha(ra);
fill(rc);
noStroke();
ellipse(i,j,1,1);
}
}
}
pop();
noLoop();
}
显示效果如下。
背景绘制
然而,使用p5js
原生的draw()
函数绘制速度很慢,因为其是使用CPU一个像素一个像素来进行绘制的。
故我们可以使用shaders
通过GPU并行计算 ,来加速绘制速度。还不会使用shaders
来绘制的小伙伴,可以见我以往的翻译教程。由于这边的绘制比较简单,故我直接放上shaders
代码。
ini
<script id="vertexShader" type="x-shader/x-vertex">
#ifdef GL_ES
precision mediump float;
#endif
attribute vec3 aPosition;
void main() {
vec4 positionVec4 = vec4(aPosition, 1.0);
positionVec4.xy = positionVec4.xy * 2.0 - 1.0;
gl_Position = positionVec4;
}
</script>
arduino
<script id="fragmentShader" type="x-shader/x-fragment">
#ifdef GL_ES
precision mediump float;
#endif
uniform vec2 u_resolution;
uniform vec3 u_color;
float random(vec2 co){
return fract(sin(dot(co.xy, vec2(12.9898,78.233))) * 43758.5453);
}
float map(float x, float o_min, float o_max, float r_min, float r_max){
return (x-o_min)/(o_max-o_min)*(r_max-r_min)+r_min;
}
二、花瓣绘制
获取花瓣的贝塞尔曲线起始点和控制点
工具:PS
- 新建画布,大小最好和processing或p5js的画布大小一致;
- 使用钢笔工具绘制出想要的花瓣形状;
- 将工具换成直接选择工具
(快捷键 A)
,打开信息面板,将鼠标移至曲线控制点或起始点处,记录下信息面板中的x与y值(如下图所示)。
使用PS绘制花瓣
若出现以下情况,即一条曲线(绿色箭头所指曲线)由三个点构成,则将第一个控制点坐标为起始点,即起始点坐标、起始点坐标、第二个控制点坐标、结束点。
- 记录下所有点的坐标,格式为:起始点坐标、第一个控制点坐标、第二个控制点坐标、结束点(另一条曲线的起始点)、另一条曲线的第一个控制点、另一条曲线的第二个控制点、另一条曲线的结束点......如下所示。
less
// 存放了四朵不同形状花瓣的集合
var bezierFlowerSet = [ [[261.99,323.60],[258.37,310.12],[246.21,233.20],[267.91,255.88],[288.29,278.56],
[258.37,310.12],[261.99,323.60]],
[[261.99,323.60],[263.31,292.04],[242.60,254.89],[266.92,254.57],[294.20,255.88],
[263.31,292.04],[261.99,323.60]],
[[268.06,251.14],[271.77,264.11],[242.74,296.84],[263.12,319.08],[284.94,341.93],
[271.77,264.11],[268.06,251.14]],
[[284.87,311.02],[317.59,302.05],[315.28,294.52],[322.81,284.67],[331.78,275.11],
[347.13,269.61],[351.19,271.35],[354.66,273.67],[363.64,279.75],[361.90,291.91],
[360.45,304.07],[348.87,308.71],[339.60,310.15],[332.36,311.31],[317.88,301.76],
[284.87,311.02]],
]
- 通过坐标转换将花瓣的起始点从PS中的坐标位置移到我们所想要的坐标位置,即将花瓣的所有点坐标(包括起始点和控制点)全部转化为相对于第一个起始点位置的相对坐标(而不是绝对坐标) ,函数如下所示。
ini
// i为花瓣数组的索引,即选择第i种花瓣形状
function calShape(arrSet,i) {
var points = [];
for(var j = 0;j < arrSet[i].length;j++){
points.push(new Point(arrSet[i][j][0]-arrSet[i][0][0],
arrSet[i][j][1]-arrSet[i][0][1]));
}
return points;
}
花瓣绘制完整代码如下所示。
scss
// 存放了四朵不同形状花瓣的集合
var bezierFlowerSet = [ [[261.99,323.60],[258.37,310.12],[246.21,233.20],[267.91,255.88],[288.29,278.56],
[258.37,310.12],[261.99,323.60]],
[[261.99,323.60],[263.31,292.04],[242.60,254.89],[266.92,254.57],[294.20,255.88],
[263.31,292.04],[261.99,323.60]],
[[268.06,251.14],[271.77,264.11],[242.74,296.84],[263.12,319.08],[284.94,341.93],
[271.77,264.11],[268.06,251.14]],
[[284.87,311.02],[317.59,302.05],[315.28,294.52],[322.81,284.67],[331.78,275.11],
[347.13,269.61],[351.19,271.35],[354.66,273.67],[363.64,279.75],[361.90,291.91],
[360.45,304.07],[348.87,308.71],[339.60,310.15],[332.36,311.31],[317.88,301.76],
[284.87,311.02]],
]
function setup() {
createCanvas(640,640);
background(255);
}
function draw(){
for(var i = 0;i < bezierFlowerSet.length;i++){
var shape = calShape(bezierFlowerSet,i);
push();
noStroke();
fill(255,0,0,100);
beginShape();
var x = width/bezierFlowerSet.length*i+20;
var y = height/2
vertex(x,y);
for(var k = 1;k < shape.length;k+=3){
bezierVertex(shape[k].x + x,shape[k].y + y,
shape[k+1].x + x,shape[k+1].y + y,
shape[k+2].x + x,shape[k+2].y + y,)
}
endShape();
pop();
}
noLoop();
}
function calShape(arrSet,i) {
var points = [];
for(var j = 0;j < arrSet[i].length;j++){
points.push(new Point(arrSet[i][j][0]-arrSet[i][0][0],
arrSet[i][j][1]-arrSet[i][0][1]));
}
return points;
}
class Point {
constructor(tempX,tempY) {
this.x = tempX;
this.y = tempY;
}
}
生成花瓣如下所示。
单片花瓣
三、花朵绘制
有了花瓣以后,将每一种花瓣绕其中心点旋转一周,便可得到完整的花朵,同时放大花瓣并调低其透明度,按照刚才的旋转方案进行再一次旋转,便可绘制更具艺术感的花朵。
为了添加花朵形态的多样性,我还添加了aside
属性,来标明这朵花是否以侧面 来面对我们的,此属性可通过减少花瓣的数量和限制花瓣的旋转角度来实现。
具体代码如下所示。
kotlin
class Flower {
constructor(tempX,tempY,petalsNum,tempC,tempS) {
var flowerNum = bezierFlowerSet.length + bigFlowerSet.length; //不同形态花瓣的数量
this.x = tempX;
this.y = tempY;
this.petals = int(petalsNum); // 在一朵花朵中花瓣的数量
this.color = tempC;
this.size = tempS; // 调整花朵的大小,使更加有多样性
// 判断是否为侧向花朵
if (random(1) < 0.7) {
this.aside = false;
}else {
this.aside = true;
}
var shapeIndex = int(random(flowerNum)); // 选择花瓣的形态
if (shapeIndex < bezierFlowerSet.length) {
this.shape = calShape(bezierFlowerSet,shapeIndex);
} else {
this.shape = calShape(bigFlowerSet,shapeIndex-bezierFlowerSet.length);
this.petals = int(random(8,10));
if (!this.aside) {
this.size /= 2;
}
}
if (this.aside) { // 设置侧向花朵的属性,即减少花瓣数量和限制旋转角度
this.rotateA = random(PI/3,PI/2);
this.petals = int(random(10,12));
this.rotateAs = random(-PI/4,PI/4); // 改变花瓣的起始角度,来使得侧面花具备多样性
}else {
this.rotateA = TWO_PI; // 完整的花朵
}
}
display(){
// 缩放花朵大小
translate(this.x,this.y);
scale(this.size);
translate(-this.x,-this.y);
for(var j = 0;j < this.petals-1;j++){ // 绘制花朵
push();
translate(this.x,this.y);
rotate(map(j,0,this.petals-1,0,this.rotateA)); // 根据不同的j来计算当前该旋转的角度
translate(-this.x,-this.y);
translate(this.x,this.y); // 根据花朵是否是侧向花来决定是否需要缩放
if (this.aside) {
scale(randomGaussian(1,0.05));
}else {
scale(randomGaussian(1,0.03));
}
translate(-this.x,-this.y);
if (this.aside) { // 若为侧向花,则调整初始角度
translate(this.x,this.y);
rotate(this.rotateAs);
translate(-this.x,-this.y);
}
push();
translate(this.x,this.y); // 根据实际情况,再次调整花朵大小。其实感觉这边有点重复了,但懒得改了。。xD
scale(randomGaussian(0.4,0.01));
translate(-this.x,-this.y);
this.color.setAlpha(200); // 设置花瓣内围透明度
noStroke();
fill(this.color);
beginShape(); // 绘制内围花瓣
vertex(this.x,this.y);
for(var k = 1;k < this.shape.length;k+=3){
bezierVertex(this.shape[k].x+this.x,this.shape[k].y+this.y,
this.shape[k+1].x+this.x,this.shape[k+1].y+this.y,
this.shape[k+2].x+this.x,this.shape[k+2].y+this.y,)
}
endShape();
pop();
translate(this.x,this.y);
if (this.aside) {
scale(randomGaussian(1,0.05));
}else {
scale(randomGaussian(1,0.02));
}
translate(-this.x,-this.y);
beginShape(); // 绘制外围花瓣
this.color.setAlpha(100);
fill(this.color);
noStroke();
vertex(this.x,this.y);
for(var k = 1;k < this.shape.length;k+=3){
bezierVertex(this.shape[k].x+this.x,this.shape[k].y+this.y,
this.shape[k+1].x+this.x,this.shape[k+1].y+this.y,
this.shape[k+2].x+this.x,this.shape[k+2].y+this.y,)
}
endShape();
pop();
}
}
}
四、树枝绘制
树枝绘制就比较简单了,就是创建一个数组,其中包含了一段树枝中的各个连接点的信息,通过将连接点以不同strokeWeight连接起来,便可实现树枝的绘制,多说无益,可以看看代码来进行理解。
kotlin
class Branch {
constructor(tempX,tempY,tempL) {
this.x = tempX; // 树枝的最底下顶点坐标
this.y = tempY;
this.l = tempL; // 树枝的长度
this.points = []; // 树枝连接点数组
this.num = 6; // 树枝连接点数量
this.points.push(new Point(this.x,this.y)); // 将第一个点压入points数组
for(var i = 0;i < this.num;i++){ // 越往上的树枝点就越偏离树枝中心轴的位置
this.points.push(new Point(randomGaussian(this.x,map(i,0,this.num-1,0,5)),this.y-this.l/this.num*i+randomGaussian(0,5)));
}
'''
new Point(x,y)
其中x为randomGaussian(this.x,map(i,0,this.num-1,0,5)) 后面这个map是用来控制正态分布的方差,简单理解就
是偏离树枝中心轴的位置
y为this.y-this.l/this.num*i+randomGaussian(0,5)
'''
this.points = this.points.sort(function (a, b) { // 按照y值大小对连接点进行排序
return b.y - a.y;
})
}
display(){
push();
noFill();
stroke(75,87,62,random(100,255));
strokeWeight(6);
for(var i = 1;i < this.points.length;i++){ // 使用不同的strokeWeight来绘制树枝,越往上的树枝越细
strokeWeight(6-6/this.points.length*i);
line(this.points[i-1].x,this.points[i-1].y,this.points[i].x,this.points[i].y);
}
pop();
}
}
function draw(){
for (var i = 0; i < branches.length; i++) {
push();
var roA = map(flowerNum,8,19,PI/25,PI/10); // 根据花朵的数量多少来控制树枝的旋转角度,防止太密集或者太稀疏
// 将树枝绕其从下往上数第三个节点旋转一定的角度
translate(branches[i].points[2].x,branches[i].points[2].y);
var angle = map(i,0,branches.length-1,-roA,roA);
rotate(angle);
translate(-branches[i].points[2].x,-branches[i].points[2].y);
branches[i].display(); // 绘制树枝
pop();
}
}
为了让树枝的绘制更加生动,我们通过粒子运动来实现树枝的动态绘制,效果如下所示。
树枝绘制
简单来讲,就是将之前所创建每个连接点的位置计算出运动方向 ,然后让粒子每次运动一段树枝小节的距离(即两个连接点的距离),同时在运动中,让粒子的大小逐渐缩小,从而实现了树枝底端粗,枝端细的效果。粒子运动和渲染代码如下所示。
kotlin
class Branch {
......
display(){
if(! this.finished){
push();
noStroke();
fill(75,87,62,this.ca);
let idxLen = dist(this.points[this.idx].x,this.points[this.idx].y,this.points[this.idx+1].x,this.points[this.idx+1].y);
let ratio = dist(this.x,this.y,this.points[this.idx+1].x,this.points[this.idx+1].y) / idxLen;
let br = ratio*this.sizes[this.idx]+(1-ratio)*this.sizes[this.idx+1];
circle(this.x,this.y,br*2);
pop();
}
}
move(){
// console.log(this.idx);
if (this.idx == this.num) {
this.finished = true;
return true;
}
if(dist(this.x,this.y,this.points[this.idx+1].x,this.points[this.idx+1].y) < 5){
this.idx += 1;
if (this.idx == this.num) {
this.finished = true;
return true;
}
}
let branchDir = [this.points[this.idx+1].x-this.points[this.idx].x,this.points[this.idx+1].y-this.points[this.idx].y];
branchDir = [branchDir[0]/dist(branchDir[0],branchDir[1],0,0),branchDir[1]/dist(branchDir[0],branchDir[1],0,0)];
this.x += branchDir[0]*this.v;
this.y += branchDir[1]*this.v;
return false;
}
}
五、绘制蝴蝶结
蝴蝶结主要就是通过贝塞尔曲线 来绘制,由于想要增加艺术感,我使蝴蝶结的贝塞尔曲线以不同的大小来进行叠加,从而呈现出一种油画的感觉。
scss
// 蝴蝶结的贝塞尔曲线
var bezierButterflySet = [ [[284.87,311.02],[277.92,303.78],[235.93,315.37],[224.06,314.21],[214.50,313.05], // 蝴蝶结左半边形状
[209,299.73],[209.87,295.10],[211.02,291.91],[206.68,280.04],[226.08,272.51],
[238.54,268.45],[277.92,304.94],[284.87,311.02]],
[[284.87,311.02],[317.59,302.05],[315.28,294.52],[322.81,284.67],[331.78,275.11], // 蝴蝶结右半边形状
[347.13,269.61],[351.19,271.35],[354.66,273.67],[363.64,279.75],[361.90,291.91],
[360.45,304.07],[348.87,308.71],[339.60,310.15],[332.36,311.31],[317.88,301.76],
[284.87,311.02]],
[[284.87,311.02],[284.87,311.02],[253.30,327.75],[242.01,380.75]], // 蝴蝶结左下角带子
[[284.87,311.02],[284.87,311.02],[342.21,356.13],[328.89,380.45]] // 蝴蝶结右下角带子
]
// 绘制蝴蝶结
function drawButterfly() {
var x_all = 0; // 蝴蝶结的位置
var y_all = 0;
var butterc = fc; // 蝴蝶结的颜色
var lineDelta = map(flowerNum,8,19,2,5); // 蝴蝶结中心的块
for(var i = 0;i < branches.length;i++){ // 通过树枝的第三个节点即旋转节点位置来计算蝴蝶结的中心位置
x_all += branches[i].points[2].x;
y_all += branches[i].points[2].y;
}
x_all /= branches.length;
y_all /= branches.length;
for(var i = 0;i < branches.length;i++){
push();
butterc.setAlpha(120); // 设置不透明度
stroke(butterc);
strokeWeight(3);
noFill();
var deltaY = map(i,0,branches.length-1,-5,5)+randomGaussian(0,2);
line(x_all-lineDelta,y_all + deltaY,x_all+lineDelta,y_all+deltaY); // 绘制蝴蝶结中心的块
pop();
}
push();
var butternum = 1000; // 叠加数量
for(var i = 0;i < butternum;i++){
var s = (butternum-i)/butternum/2; // 计算当前的缩放大小
var a = constrain(map(i,0,butternum,0,200),0,random(200)); // 计算当前的不透明度
push();
// 对蝴蝶结进行缩放
translate(x_all,y_all);
scale(s);
translate(-x_all,-y_all);
noFill();
strokeWeight(1);
butterc.setAlpha(a);
stroke(butterc);
beginShape(); // 开始绘制蝴蝶结的左半边形状
vertex(x_all,y_all);
for(var k = 1;k < butterflySet[0].length;k+=3){
bezierVertex(butterflySet[0][k].x+x_all,butterflySet[0][k].y+y_all,
butterflySet[0][k+1].x+x_all,butterflySet[0][k+1].y+y_all,
butterflySet[0][k+2].x+x_all,butterflySet[0][k+2].y+y_all)
}
endShape();
beginShape(); // 开始绘制蝴蝶结的右半边形状
vertex(x_all,y_all);
for(var k = 1;k < butterflySet[1].length;k+=3){
bezierVertex(butterflySet[1][k].x+x_all,butterflySet[1][k].y+y_all,
butterflySet[1][k+1].x+x_all,butterflySet[1][k+1].y+y_all,
butterflySet[1][k+2].x+x_all,butterflySet[1][k+2].y+y_all)
}
endShape();
// 绘制蝴蝶结的左下的带子
bezier(x_all,y_all,x_all,y_all,butterflySet[2][2].x+x_all,butterflySet[2][2].y+y_all,
butterflySet[2][3].x+x_all,butterflySet[2][3].y+y_all);
// 绘制蝴蝶结的右下的带子
bezier(x_all,y_all,x_all,y_all,butterflySet[3][2].x+x_all,butterflySet[3][2].y+y_all,
butterflySet[3][3].x+x_all,butterflySet[3][3].y+y_all);
pop();
}
}