让图形跑起来(下篇)

运动与函数

在大多人的印象里,数学好像没什么用,在日常生活里用得最多的也仅仅是加减乘除。

但如果你是在用程序做创作,情况就大不一样。了解越多,越能玩出花样。

先放几张的不明觉厉的图挑逗大家的兴致。

这是什么?现在先不剧透,后面你会亲自用上它。

上一节,我们了解了 setup 函数和 draw 函数,这使得静止的图形可以运动起来。但这种运动形式太朴素了,我们要用上以前掌握的函数知识,让图形跑出自己的个性。

上面的数学函数还能认出多少?它们与运动有何关系?

先从中选一个二次函数,同时添加一些参数看看,比如 y = x² / 100

它的函数图像是这样的,复制下面这段代码

--代码示例(3-1):

— ofApp.h内 —

float x, y;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300, 300);
    ofSetBackgroundAuto(false);
    ofBackground(0);
    x = 0; // 此句可省略,变量声明后,初值默认为 0
}
void ofApp::update(){
    x++;
}
void ofApp::draw(){
    ofSetColor(255); // 此句可省略,填充色彩默认为白色
    y = pow(x,2) / 100.0;
    ofDrawCircle(x,y,1);
}

运行效果。

接着再选一个 sin 函数, y = 150 + sin(x)

复制下面这段代码。

--代码示例(3-2):

— ofApp.h内 —

float x, y;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300, 300);
    ofSetBackgroundAuto(false);
    ofBackground(0);
}
void ofApp::update(){
    x++;
}
void ofApp::draw(){
    y = ofGetHeight()/2 + sin(ofDegToRad(x)) * 150;  //ofDegToRad 函数将角度值转成弧度值
    ofDrawCircle(x,y,1);
}

运行效果:

以上是它们的运动轨迹。对照着上图和下图,结果是显而易见的。函数图像其实就对应着运动轨迹。相当简单,只要将函数中的 x,y 的值替换进绘图函数的横纵坐标中就能绘制出图像。第一张绘制的轨迹,其实就等价于函数 y = x² / 100 的图形。而第二张的轨迹,等价于 y = 150 + sin(x) 。只是在程序中,由于 y 轴方向是反的,所以与原图相比,图形轨迹会上下颠倒。

现在应该有种阔然开朗的感觉。以前学习的各式稀奇古怪的函数,原来可以在程序中控制图形的运动!

数学函数在程序中怎么写?

下面罗列了一些使用频率很高的函数,可以帮助我们将数学函数翻译成计算机能识别的代码

函数名 作用 格式
abs 返回指定数的绝对值 abs(x)
log 返回以自然对数 e 为底,n 的对数 log(n)
sqrt 返回指定数的平方根 sqrt(x)
pow 返回指定数的 n 次方 pow(x,n)
exp 返回自然对数 e 的 n 次方 exp(n)

因此下面这些式子,在程序中就可以这样写。

y = x² → y = pow(x, 2)

y = x³ → y = pow(x, 3)

y = xⁿ → y = pow(x, n)

y = 4ⁿ → y = pow(4, n)

y =logₑ² → y = log(2)

y = e² → y = exp(2)

y = √5 → y = sqrt(5)

你可以尝试在程序里写个函数,观察它的运动轨迹。但请记得考虑函数的值域和定义域的范围,否则你画的图很可能会跑在屏幕之外。

三角函数

下面再来了解一下与三角函数相关的函数写法

函数名 作用 格式
sin 正弦函数 sin(x)
cos 余弦函数 cos(x)
tan 正切函数 tan(x)
ofDegToRad 将角度值转化成弧度值 ofDegToRad(x)
ofRadToDeg 将弧度值转化为角度值 ofRadToDeg(x)

值得注意的是,在程序中,与角度相关的函数参数输入采取的是弧度制。所以 sin90° ,应该写成 sin(PI/2)。如果不熟悉这种方式,也可以用 ofDegToRad 函数将角度先转换为弧度 ,写成 sin(ofDegToRad(90))。

ofRadToDeg 函数的作用恰恰相反,可以将弧度值转化为角度值。尝试在 setup 中写下这行代码。看看结果会是多少?

cout << ofRadToDeg(PI/2) << endl;

用三角函数控制图形运动

下面给出一个范例,看看实际的图形运动效果

--代码示例(3-3):

— ofApp.h内 —

float x, y;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(700, 300);
    ofBackground(234, 113, 107);
}
void ofApp::update(){
    x++;
}
void ofApp::draw(){
    y = sin(ofDegToRad(x)) * 150 + 150;
    ofDrawCircle(x,y,25);
}

  • sin函数是周期函数,最小值是-1,最大值为 1 。屏幕高度为 300 。根据 y = sin(ofDegToRad(x)) * 150 + 150; ,因此 y 值的变化范围就会刚好控制在 0 到 300 之内。

旋转的圆

这节的重头戏到了。那我们怎样在程序中画一个圆的轨迹?可以用什么函数去表示?再次搬出这两张图~~

它们其实很直观地揭示了圆周坐标与三角函数的关系。图上所有的运动,都是通过不断增大自变量 θ 来驱动的。左边其实就是 sin 函数与 cos 函数的图像,右边代表的是经过映射后,一个作圆周运动的点。现在看起来一点也不神秘了,你还可以用代码去实现它!

--代码示例(3-4):

— ofApp.h内 —

float x, y, r, R, angle;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300, 300);
    ofBackground(234, 113, 107);
    r = 10;          //圆的半径
    R = 100;        //运动轨迹的半径
}
void ofApp::update(){
    angle += 0.05;
    x = ofGetWidth()/2 + R * cos(angle);
    y = ofGetHeight()/2 + R * sin(angle);
}
void ofApp::draw(){
    ofDrawCircle(x,y,r);
}

一个旋转的圆出现了。在这里,自变量不再是不断递增的 x ,而是变成了 angle( 也相当于例图中的 θ )。它代表角度。其中 xy 都分别乘以系数 R,也就相当于扩大了圆的运动半径( R 代表运动半径 )。若是不乘以 R ,它的图形变化轨迹只会局限在 -1 到 1 的范围。前面加上 ofGetWidth()/2 和 ofGetHeight()/2 相当于将旋转中心平移到画布中央。

但有没有想过,为什么不能像前面的函数一样,只用一个用不断递增的 x 来画出图形?根据函数自身的特性,定义域中任意 x ,有且只有一个 y 与之相对应。所以在平面直角坐标系里,你无法寻找到一个“简单函数”直接画出圆。

不能用这种形式

y = (包含x的神秘表达式?) ;
x++ ;

所以才需要拐个弯,找一个 angle 来作为自变量。并用 sin 和 cos 函数将它转化为横纵坐标。

x = R * cos(angle);
y = R * sin(angle);
angle += 0.05;

当然有人会好奇,为何这样就能表示圆的运动轨迹?根据三角函数的定义其实是不难推出的。sin 函数是对边与斜边之比,cos 函数是邻边与斜边之比。无论圆周上的点位置在哪,r(半径)都是不变的。因此可以得到 x 坐标与 y 坐标的表达式

由于这个不是数学指南,有关三角函数的知识,就不在这里展开了。如果确实忘记,后面也会有相关的章节去回顾。

当然,不完全理解也没有任何问题,只要知道怎么用它画圆即可。这也是一种“编程思维”,以后我们常常需要调用一些别人做好的模块来实现某种功能。所以无需有强迫症的心态,非去搞清里面的细节。

但 sin 和 cos 在创意编程中太常用了,若是想进行更高阶的创作,尽量把它想明白。

上面几张动图,都与三角函数密切相关。

运动的坐标系

之前的效果,都是图形坐标在变化,坐标系本身是静止的。其实我们可以通过让坐标系动起来,来实现运动效果。这就好比岸上的人看船上的人,船上的人相对船是静止,但若是船本身在动,从岸上看去,人也就动了。所以前面讲的例子,一直都是“人在船上跑”,船并没有动。

下面是变换坐标系的常用函数

函数 作用
ofTranlate(x, y) 平移坐标系
ofScale(a) 缩放坐标系
ofRotate(a) 旋转坐标系
ofPushMatrix(), ofPopMatrix() 变换堆栈(存取坐标系)

ofTranslate函数

ofTranslate函数前面有提到过,用于平移图形的坐标系

调用形式:

ofTranslate(a, b)

第一个参数代表往 x 轴的正方向移动 a 个像素,第二个参数代表往 y 轴的正方形移动 b 个像素。

  • 以下实例的窗口大小设置为(100 X 100) , 对比两段代码,观察有何不同

--代码示例(3-5):

使用前:

void ofApp::draw(){
    ofDrawCircle(0,0,10);
}

使用后:

void ofApp::draw(){
    ofTranslate(50,50);
    ofDrawCircle(0,0,10);
}

ofRotate函数

调用形式:

ofRotate(a)

函数用于旋转坐标系 ,当参数为正数,会以原点为中心,往顺时针方向旋转。传入的参数和三角函数一样,采取弧度制。

--代码示例(3-6):

使用前:

void ofApp::draw(){
    ofDrawCircle(50,50,10);
}

使用后:

void ofApp::draw(){
    ofRotate(30);
    ofDrawCircle(50,50,10);
}

在程序中产生的作用,就是让圆围绕坐标原点,顺时针旋转 30 度。

ofScale函数

调用形式:

ofScale(a,b)

函数可以缩放坐标系,参数 a 缩放 x 轴方向,参数 b 缩放 y 轴方向。 数值大小代表缩放的倍数。大于 1 放大,小于 1 则缩小。

--代码示例(3-7):

使用前:

void ofApp::draw(){
    ofDrawCircle(0,0,10);
}

使用后:

void ofApp::draw(){
    ofScale(4,4);
    ofDrawCircle(0,0,10);
}

上图的圆就放大到原来的四倍了。你也可以使用两个参数,分别

void ofApp::draw(){
    ofScale(4,2);
    ofDrawCircle(0,0,10);
}

变换函数在 OF 与 P5 中的异同对比

  • P5 中的 translate,rotate,scale 分别对应 OF 中的 ofTranslate, ofRotate, ofScale
  • P5 中变换函数的参数输入采取弧度制,OF 中则采取角度制。
  • P5 中的 scale 允许输入单个参数,OF 中的 ofScale 参数数量必须为 2 个或 3个。

变换函数的叠加

这里的变换都是相对当前坐标系的变换。换句话说,效果是可以叠加的。

--代码示例(3-8):

void ofApp::draw(){
    ofTranslate(40, 10);
    ofTranslate(10, 40);
    ofDrawCircle(0,0,10);
}

最终效果就等价于

void ofApp::draw(){
    ofTranslate(50, 50);
    ofDrawCircle(0,0,10);
}

ofRotate函数也一样

--代码示例(3-9):

void ofApp::draw(){
    ofRotate(10);
    ofRotate(20);
    ofDrawCircle(50,50,10);
}

等价于

void ofApp::draw(){
    ofRotate(30);
    ofDrawCircle(50,50,10);
}

由于 ofScale 和 ofRotate,都是以原点为中心进行缩放和旋转的。当我们希望一个中心位置在(50,50)的图形产生旋转的效果。就需要倒过来思考,首先将坐标原点移动到(50,50)的位置,再添加旋转变换函数,最后才把图形绘制在原点上。

--代码示例(3-10):

使用前:

void ofApp::draw(){
    ofDrawEllipse(50, 50, 50, 20);
}

使用后:

void ofApp::draw(){
    ofTranslate(50,50);
    ofRotate(45);
    ofDrawEllipse(0, 0, 50, 20); //为了看出旋转的角度变化,绘制一个椭圆
}

平移与圆周运动

下面的例子会通过变换坐标系来实现运动效果。很多时候,在程序中实现特定的效果,完全可以用截然不同的手法。

平移运动

--代码示例(3-11):

— ofApp.h内 —

int x,y;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300,300);
    y = ofGetHeight()/2;
}

void ofApp::update(){
    x++;
}

void ofApp::draw(){
    ofBackground(234, 113, 107);
    ofTranslate(x,y);
    ofDrawCircle(0,0,25,25);
}

圆的绘制坐标本身没有变化,被改变的是所在的坐标系。

旋转运动

--代码示例(3-12):

— ofApp.h内 —

float r, R, angle;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300,300);
    r = 10;
    R = 100;
}

void ofApp::update(){
    angle ++;
}

void ofApp::draw(){
    ofBackground(234, 113, 107);
    ofTranslate(ofGetWidth()/2,ofGetHeight()/2);
    ofRotate(angle);
    ofDrawCircle(0, R ,r);
}

是否比用三角函数画圆更简洁,也更易理解了?这里有人可能会有疑问,以旋转运动的代码为例。前面提过的变换函数明明是相对的,而且允许叠加效果。那 ofTranslate(ofGetWidth()/2,ofGetHeight()/2); 写在 draw 函数里,岂不代表 draw 函数每运行一次,坐标系都会在原基础上往右下方移动一段距离,理论上是不会永远保持在屏幕中心?

可以这样去理解,draw 函数里的代码只要由上到下跑完一次,第二次循环时坐标系都会回到始初状态,坐标系的原点会默认回到左上角上。所以要想坐标系维持持续的变化,ofRotate 函数中的 angle 数值,就需要不断递增。

存取坐标状态

有些时候,我们不希望坐标系的状态是在之前的基础上变换。这时就要用到 ofPushMatrix 和 ofPopMatrix 。这两个函数是成对出现的,ofPushMatrix 在前 ofPopMatrix 在后。不能单独使用,否则就会出错。

--代码示例(3-13):

void ofApp::draw(){
   ofPushMatrix();    //保存坐标系状态
    ofTranslate(50, 50);
    ofDrawCircle(0, 0, 10);
    ofPopMatrix();     //读取坐标系状态
    ofDrawRectangle(0, 0, 20, 20); 
}

例子中,在使用 ofTranslate(50,50) 前,先用 ofPushMatrix。就会保存坐标系当前的状态,这同时也是坐标原点在左上角的初始状态。当绘制完圆形后,再执行 popMatrix ,就会还原到到这个状态。此时再执行 ofDrawRectangle ,会发现它没有受到 ofTranslate 的影响。而是在左上角的原点上绘制了一个正方形。

另外,ofPushMatrix 和 ofPopMatrix 是允许嵌套使用的。

例如

pushMatrix(); … pushMatrix(); … popMatrix(); … popMatrix(); …

  • 为了更直观地表明对应关系,采取了缩进的形式

组合运动,运动中的运动?

前面用船和人的例子作比喻。是否有想过,如果船上的人和船都动起来,岸上的人看过去会是怎样的一番体验?

假如平移运动与坐标系的旋转运动组合到一起?这里的点其实只朝一个方向运动~

--代码示例(3-14):

— ofApp.h内 —

int x, y;
float angle;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300,300);
    ofBackground(234, 113, 107);
    ofSetBackgroundAuto(false);
}

void ofApp::update(){
    angle += 15;
    y--;
}

void ofApp::draw(){
    ofTranslate(ofGetWidth()/2, ofGetHeight()/2);
    ofPushMatrix();
    ofRotate(angle);
    ofDrawCircle(x, y, 3);
    ofPopMatrix();
}

也可以是圆周运动与坐标系的缩放运动的组合~

--代码示例(3-15):

— ofApp.h内 —

float x, y, angle;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300,300);
    ofBackground(234, 113, 107);
    ofSetBackgroundAuto(false);
}

void ofApp::update(){
    angle += 0.01;
    x = sin(angle) * 100;
    y = cos(angle) * 100;
}

void ofApp::draw(){
    ofTranslate(ofGetWidth()/2, ofGetHeight()/2);
    ofPushMatrix();
    ofScale(1 + 0.1 * sin(angle * 10),1 + 0.1 * sin(angle * 10));
    ofDrawCircle(x, y, 3);
    ofPopMatrix();
}

可被最终的结果欺骗了,在程序中圆点只在做圆周运动。这个坐标系的缩放可以用摄像头去类比,一个不断前后运动的摄像头在拍摄一个作圆周运动的点。

以上都是非常简单的基础函数,但通过不同组合,效果却可以千差万别。之后就不透露太多了,怎能剥夺大家探索的乐趣?

综合运用

这两节指南较详细地介绍了图形运动的基本方法。相信你对运动的理解,比以前更深了。最后给出一些完整的实例供大家参考。

--代码示例(3-16):

— ofApp.h内 —

float x1, y1, x2, y2, r, R;
float angle1, angle2;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(300,300);
    ofSetBackgroundAuto(false);
    r = 6;
    R = 120;
}

void ofApp::update(){
    angle1 += 0.02;
    angle2 += 0.06;
    x1 = R * sin(angle1);
    y1 = R * cos(angle1);
    x2 = R/2 * sin(angle2);
    y2 = R/2 * cos(angle2);
}

void ofApp::draw(){
    ofBackground(234, 113, 107);
    ofTranslate(ofGetWidth()/2, ofGetHeight()/2);
    ofDrawCircle(x1, y1, r/2);
    ofDrawCircle(x2, y2, r);
    ofDrawCircle(-x1, -y1, r/2);
    ofDrawCircle(-x2, -y2, r);
    ofDrawCircle(x1, -y1, r/2);
    ofDrawCircle(x2, -y2, r);
    ofDrawCircle(-x1, y1, r/2);
    ofDrawCircle(-x2, y2, r);
    ofSetLineWidth(2); // 设置线条粗细
    ofDrawLine(x1, y1, x2, y2);
    ofDrawLine(-x1, -y1, -x2, -y2);
    ofDrawLine(x1, -y1, x2, -y2);
    ofDrawLine(-x1, y1, -x2, y2);
}

这个例子涉及的函数知识都没有超出前面的。

是不是搞不清哪个点对哪个点?哪条线对哪条线?其实我自己也搞不清楚……但我还记得它是由一小段代码衍生而来的。

这就是它的运动本质。其余的线条仅仅是镜像效果而已。

如果你继续跟随这个指南,后期还可以做一个升级版,给图形添加控件,来实时地改变图形的运动状态。

编程的有趣之处就在于可以设计规则,组合规则。但最终能写成什么程序,就看自己的造化了。设计师往往有很强的图形想象力,你既可以先在脑中勾勒出动态草图,再设法从脑中“翻译”成代码。也能从代码和法则本身出发,随意设计函数和变量。在编程世界,代码就是你的画笔!用它挥洒自己的创意吧~~

END

最后穿越回去解答一个之前的遗留问题吧。我们那么费力地用程序画一张图,究竟有有何作用?学完这章以后,有太多玩法了。

--代码示例(3-17):

— ofApp.h内 —

float browX, earR, eyeR;

— ofApp.cpp内 —

void ofApp::setup(){
    ofSetWindowShape(500,500);
    ofSetBackgroundAuto(false);
}

void ofApp::update(){
    eyeR = 30 + sin(ofGetFrameNum() / 30.0) * 25;
    earR = 90 + sin(ofGetFrameNum() / 10.0) * 10;
    browX = 150 + sin(ofGetFrameNum() / 30.0) * 20;
}

void ofApp::draw(){
    //背景色设置与线条宽度设置
    ofBackground(200, 0, 0);
    ofSetLineWidth(8);

    // 耳
    ofFill();
    ofSetColor(255);
    ofDrawCircle(175, 220, earR);
    ofDrawCircle(ofGetWidth() - 175, 220, earR);
    ofNoFill();
    ofSetColor(0);
    ofDrawCircle(175, 220, earR);
    ofDrawCircle(ofGetWidth() - 175, 220, earR);

    // 脸
    ofFill();
    ofSetColor(255);
    ofDrawRectangle(100, 100, 300, 300);
    ofNoFill();
    ofSetColor(0);
    ofDrawRectangle(100, 100, 300, 300);

    // 眉毛
    ofDrawLine(browX, 160, 220, 240);
    ofDrawLine(ofGetWidth() - browX, 160, ofGetWidth() - 220, 240);

    // 左眼
    ofFill();
    ofSetColor(ofRandom(255),ofRandom(255),ofRandom(255));
    ofDrawCircle(175, 220, eyeR);
    ofNoFill();
    ofSetColor(0);
    ofDrawCircle(175, 220, eyeR);

    // 右眼
    ofFill();
    ofSetColor(ofRandom(255),ofRandom(255),ofRandom(255));
    ofDrawCircle(ofGetWidth() - 175, 220, eyeR);
    ofNoFill();
    ofSetColor(0);
    ofDrawCircle(ofGetWidth() - 175, 220, eyeR);

    // 嘴
    ofFill();
    ofSetColor(255);
    ofDrawTriangle(170, 300, ofGetWidth() - 170, 300, 250, 350);
    ofNoFill();
    ofSetColor(0);
    ofDrawTriangle(170 - cos(ofGetFrameNum() / 10.0) * 20, 300 - sin(ofGetFrameNum() / 10.0) * 20, ofGetWidth() - (170 + cos(ofGetFrameNum() / 10.0) * 20), 300 + sin(ofGetFrameNum() / 10.0) * 20, 250, 350);

    // 鼻
    ofFill();
    ofSetColor(0);
    ofDrawCircle(ofGetWidth()/2, ofGetHeight()/2,4);

}

动图是不是比较魔性?这里不做太多文章了。留待你去设计更棒的效果。

用程序去画图,它的优势在于,可以真正做到把玩每个像素。由于绘制的不是位图,所以图上的每个关键点都是可控的,能由此实现一些其他软件难以达到的效果。

如果你有一颗想肢解一切,又重组一切的心,学习编程一定可以最大程度地满足你。