算法动画 - 理解函数曲线
這篇梳理一些有關算法動畫的生成思路。
用算法生成動畫,大致可分成兩類。一類是基于時間( time-based ),一類是基于幀( frame-based )。其中有何區別,我們先通過兩段 Processing 代碼去理解。
代碼 01( 基于幀 )
float x; void setup(){ ?size(600,200); ?x = 100; } void draw(){ ?background(239,234,228); ?if(x < 500){ ? ? ?x += 5; ?} ?fill(50,120,133); ?noStroke(); ?ellipse(x,height/2,50,50); }
代碼淺析:
代碼中創建了一個變量 x 表示圓的橫坐標。數值初始化為 100。而draw 函數中 x 每次累加 5,直到 500 停止累加。因此 x 的數值變化范圍則是從 100 到 500,實現了小球從左到右的運動
代碼 02 ( 基于時間 )
float time; void setup(){ ?size(600,200); ?time = 3; } void draw(){ ?background(239,234,228); ?float x = min(500,map(millis()/1000.0,0,time,100,500)); ?fill(50,120,133); ?noStroke(); ?ellipse(x,height/2,50,50); }
代碼淺析:
代碼中創建了一個變量 time ,表示圓從左側運動到右側的時間
millis() 表示毫秒,因而 millis()/1000.0 表示秒。通過 map 函數,將時間從 0 到 time 的變化,映射為從 100 到 500 的變化。隨著時間的遞增,實現了小球從左到右的運動
min 函數用于限定 x 的大小,讓數值不超過 500
簡單比較
通過對比兩段代碼可以發現,雖然最終結果是近似的(小球從 100 勻速運動到 500),但決定運動的條件是不同的。前者限定了每次小球每次遞增的距離,后者限定了整個運動的時間。
條件的不同,決定了在某些場景下,某種方法會比另一種方法使用起來更便利。例如要繪制一個運行速度恒定的小車,使用基于幀的算法寫起來會更簡便。若希望小車從 A 點運行到 B 點的時間是固定值,又或者實現時間間隔固定的淡入淡出效果(將數值變化映射到顏色變化),基于時間的算法則更合適。
除此之外,它們兩者間還有一個更重要的區別。使得自己在制作動畫時,更傾向使用基于時間的思路。
前面的例子中,由于繪制的都是一些非常簡單的圖形,所以程序運行必然非常流暢平穩的,維持在 60 fps。但如果程序有復雜的場景切換。某些場景繪制的元素多,占用更多計算資源。就會導致某個時間段運行幀率變慢。
我們可以設想下這個情況。假如小車每幀往前移動 1 個單位,第一秒內如果程序的幀率正常(60fps),這一秒小車就會移動 60 個單位。到第二秒開始,若場景里出現很多元素,導致程序幀率變成 20 fps了,由于小車每幀累加的值是固定的,所以這一秒,小車就只移動了 20 個單位。合起來,在兩秒的時間中,小車只移動了 80 個單位。相比幀率恒定的情況下移動 120 個單位,小車移動的距離明顯變小了。而且整個動畫連起來看,小車做的就不再是勻速運動,出現先快后慢的結果。
為了避免這種情況,如果用基于時間的寫法,效果就大有不同。因為這時小車的位置是根據時間的流逝多少決定的。它能保證在相應時間內,小車的位置都在“正確”的地方。只是幀率低的時候,畫面運動的流暢度降低而已,整體小車的運行速度并沒有變化。
基于這種特性。游戲中的運動基本是采用基于時間的算法去實現的。畢竟不同玩家的電腦配置可能有很大的區別,如果開發一個賽場游戲,汽車運動算法是基于幀的。那電腦配置高的玩家,車的速度就變快,這顯然是不合理的。
運動的自然之道 - 使用函數曲線
我們再來看前面寫的小球動畫。雖然它是動起來了,但顯得很呆板。為何會產生這種感覺?這是因為違背了人的視覺經驗。在日常生活中,我們很難看見一個物體從完全靜止的狀態突然變成勻速運動的狀態,也很難看到一個運動中的物體瞬間靜止。
要改善這種狀況,一個簡單的方式是引入“力”。比如下面的例子,實現了小球從靜止到加速。
代碼 03 ( 加速運動 )
float posX; float acc; float vel; void setup(){ ?size(600,200); ?posX = 100; } void draw(){ ?background(239,234,228); ?acc = 0.5; ?vel += acc; ?posX += vel; ?if(posX > 500){ ? ?posX = 500; ?} ?fill(50,120,133); ?noStroke(); ?ellipse(posX,height/2,50,50); }
如果希望上面的小球在快接近目標的時候有減速的效果,就需要在上面增加一些屬性或是添加判定條件。這樣的做法顯然有些繁瑣,而且仍舊是“基于幀”的。如果我們希望準確地控制小球的運動時間,僅用上面代碼是無法做到的。
有更簡便的方式嗎?函數曲線此時就可以派上用場。
函數曲線
我們先選一個典型的數學函數 sin
再結合圖像理解下面代碼
代碼 04 ( 加速到減速 )
float time; void setup(){ ?size(600,200); ?time = 3; } void draw(){ ?background(239,234,228); ?float sinInput = map(min(time,millis()/1000.0),0,time,-PI/2,PI/2); ?float x = map(sin(sinInput),-1,1,100,500); ?fill(50,120,133); ?noStroke(); ?ellipse(x,height/2,50,50); }
代碼淺析:
相比代碼 03,例子 04 并沒有用到速度,加速度等變量。但仍然可以看到小球有加速,減速的運動變化,而且可以通過 time 變量去控制小球的運動時間
雖然運動并不嚴格遵循牛頓力學,但整體效果還是比較自然的。它很巧妙地利用了 sin 函數曲線的變化來映射小球的位置變化。具體的操作,是在 x 方向上截取一段合適的區間,然后將對應函數值 y 的變化,映射到我們需要的變化區間之內。若有模糊的地方,可以對照下圖去理解
藍線可以看成是“時間”(時間流逝速率恒定)。請腦補一個動畫,藍線以恒定的速度從 -0.5 π 的位置從左往右移動到 0.5 π 的位置。它與函數曲線的交點為 A。此時 A 點的 y 坐標就表示函數的輸出值。可以看出在這個區間內移動,sin 函數的輸出值就會從 -1 變化到 1。但這個輸出的變化值我們不能直接使用,需要通過 map 函數,將它映射到在我們想要的范圍內變化。
sin 函數在這里其實就是一個中轉站。只是使用它前,需要將輸入值和輸出值做兩次處理 (調用兩次 map)。第一次調用 map 函數,就是將時間從 0 到 time 的變化,映射為 -PI/2 到 PI/2 之間的變化,再傳入函數中。第二次調用 map,則是函數的輸出值映射為我們需要的位置數值。
同一個函數,選擇的輸入區間不同。得出的結果也不同。假如選擇從 A 點到 B 點作為變化區間,整體的運動速率就是先慢后快的加速過程。如果選擇從 B 點到 C 點,則整體的運動速率就是先快后快的減速過程。要判斷是加速還是減速,可以對照函數曲線。越平的地方,就代表運動越慢,越陡峭,就表明運動變化越快。
指數函數
當理解了上面的思路。現在數學函數就可以成為你的創作素材。常用的數學函數除了三角函數 sin,cos。還有指數函數。
一般地,y = a^x函數(a為常數且以a>0,a≠1)叫做指數函數。下圖是 y=2^x 的圖像。
指數函數在 Processing 中寫作
pow(a,b)
其中 a 表示底數,b 表示指數。pow(2,2) 表示 2 的 3 次方,結果為 8。
有關指數函數的用法就不再展開,與上面例子是類似,找準輸入輸出區間再作映射即可。函數曲線的使用是非常靈活的。不僅可以單獨使用,還可以組合使用。例如兩個基本函數進行相加和相成,都會得到意想不到的效果。
延展
現在僅僅靠指數函數與三角函數,就可以產生各種不同的函數曲線。下面代碼就是指數函數與三角函數的疊加,它使得小球加速靠近的同時,能有一個來回的擺動。最終產生了帶彈性的動畫效果。
float inputVal = min(map(millis()/1000.0,0,time,0,1),1); ?float x = map(cos(inputVal * 20) * pow(2,-10.0 * inputVal),1,0,100,500);
( 替換例 04 的運動算法 )
總結
要盡可能理解函數曲線的特性。就需要多加實驗。函數曲線可不僅僅只能用在運動動畫上。下面用了 5 種常用函數輸出了幾組 gif。分別控制圖形的位置,顏色,旋轉角度,大小。可以去從中感受不同函數曲線的個性。
【 1 】線性遞增(勻速變化)
【 2 】sin 函數(區間 -PI/2 到 PI/2,從加速到減速)
【 3 】指數函數(減速)
【 4 】指數函數疊加 cos 函數(整體減速)
【 5 】sin 函數(往復)
( 控制位置 )
( 控制透明度 )
( 控制旋轉角度 )
( 控制大小 )
最后附上一張由 Kynd 整理的一張圖,里面的函數曲線都很實用,有興趣可以到此地址下載高清大圖,了解更多函數曲線? (?http://thebookofshaders.com/05/kynd.png?)
補充
函數曲線非常實用,但如果在程序中每次使用都要考慮各種映射關系,顯然有點繁瑣。更好的做法是把一些常用的函數曲線用一個類把它封裝起來。
下面分享一段自己創作時常用到的類(代碼基于C++,框架 openframeworks)
class WenzyAni{ public: float ratio; // 內部表示完成進度 (范圍一般為 0 到 1) float startVal,endVal; // 開始的數值,結束的數值 float val; // 當前的數值 float time; // 完成整個動畫所需的時間 int aniMode; // 決定數值的變化曲線類型 bool startMoving; // 是否開始運動 float startTick; // 開始的時刻記錄 WenzyAni(){ } WenzyAni(float time_,float startVal_,float endVal_,int mode_ = 0){ ? ?time = time_; ? ?startVal = startVal_; ? ?endVal = endVal_; ? ?aniMode = mode_; ? ?startMoving = false; ? ?val = startVal_; } void update(){ ? ?if(startMoving){ ? ? ? ?ratio = MIN(time,ofGetElapsedTimef() - startTick)/time; ? ? ? ?if(aniMode == 0){ ? ? ? ? ? ?// 勻速平滑過渡 ? ? ? ? ? ?val = ofMap(ratio,0,1,startVal,endVal); ? ? ? ?}else if(aniMode == 1){ ? ? ? ? ? ?// 先加速后減速(經過 sin 函數處理) ? ? ? ? ? ?float ratio2 = ofMap(sin(ofMap(ratio,0,1,-PI/2,PI/2)),-1,1,0,1); ? ? ? ? ? ?val = ofMap(ratio2,0,1,startVal,endVal); ? ? ? ?}else if(aniMode == 2){ ? ? ? ? ? ?// 持續減速(指數衰減) ? ? ? ? ? ?val = ofMap(pow(2,-10 * ratio),1,0,startVal,endVal); ? ? ? ?}else if(aniMode == 3){ ? ? ? ? ? ?// 彈簧效果 ? ? ? ? ? ?val = ofMap(cos(ratio * 20) * pow(2,-10 * ratio),1,0,startVal,endVal); ? ? ? ?}else if(aniMode == 4){ ? ? ? ? ? ?// cos 式往復 ? ? ? ? ? ?float n = 2; // n 表示往復次數 ? ? ? ? ? ?val = ofMap(cos(ratio * n * 2 * PI + PI),1,-1,startVal,endVal); ? ? ? ?} ? ?} } void start(){ ? ?startMoving = true; ? ?startTick = ofGetElapsedTimef(); } };
應用范例 01
ofApp.h 內 —-
#include “WenzyAni.h” ... WenzyAni ani; ofEasyCam cam;
ofApp.cpp 內 —-
void ofApp::setup(){ ? ?ofSetWindowShape(1000,500); ? ?ofBackground(3,27,93); ? ?ani = WenzyAni(1, -300, 300,3); } void ofApp::update(){ ? ?ani.update(); } void ofApp::draw(){ ? ?cam.begin(); ? ?ofSetColor(233,60,37); ? ?ofDrawBox(ani.val,0,0,100); ? ?cam.end(); } void ofApp::keyPressed(int key){ ? ?if(key == '1'){ ? ? ? ?ani.start(); ? ?} ? ?if(key == '2'){ ? ? ? ?ani = WenzyAni(1, -300, 300,3); ? ? ? ?ani.start(); ? ?} ? ?if(key == '3'){ ? ? ? ?ani = WenzyAni(1, 300, -300,3); ? ? ? ?ani.start(); ? ?} ? ?if(key == '4'){ ? ? ? ? ani = WenzyAni(1, -300, 300,0); ? ? ? ?ani.start(); ? ? } ? ?if(key == '5'){ ? ? ? ?ani = WenzyAni(1, -300, 300,1); ? ? ? ?ani.start(); ? ?} ? ?if(key == '6'){ ? ? ? ?ani = WenzyAni(1, -300, 300,2); ? ? ? ?ani.start(); ? ?} }
代碼淺析:
start 函數為觸發動畫的函數。運行程序后按數字鍵 1 即開始執行,可以看到正方體將從左運動到右,并且帶一點彈性動畫。這是因為 setup 中有一句
?ani = WenzyAni(1, -300, 300,3)
它將 ani 對象初始化時。第一個參數表示整個動畫的運行時間,第二個參數表示初始的數值,第三個參數表示結束時的數值。第四個參數表示選擇應用的曲線類型
draw 函數通過 ani.val 來表示正方體的橫坐標
每按下一次數字鍵 1 執行 start 函數時,正方體的運動都會從左變化到右。這是因為 startVal 與 endVal 的值在初始化時已經確定。如果希望正方形實現從右到左的運動,則需要重新初始化。按下數字鍵 3 就能實現這一效果。而來回按數字鍵 2,3 則能實現往復運動。
按數字鍵 4,5,6 可以切換不同的曲線
(數字鍵 4)
(數字鍵 5)
(數字鍵 6)
應用范例 02
下面再附上上篇文章中展示的幾個 gif 源碼,還是使用同樣的類
ofApp.h 內 —-
#include “WenzyAni.h” ... vector<WenzyAni> ani; int showMode;
ofApp.cpp 內 —-
void ofApp::setup(){ ? ?ofSetWindowShape(1920,1080); ? ?ofBackground(239,234,228); ? ?for(int i = 0;i < 5;i++){ ? ? ? ?ani.push_back(WenzyAni(2,0,1,i)); ? ?} ? ?showMode = 0; } void ofApp::update(){ ? ?for(int i = 0;i < ani.size();i++){ ? ? ? ?ani[i].update(); ? ?} } void ofApp::draw(){ ? ?ofColor myColor(50,120,133); ? ?ofSetColor(myColor); ? ?ofSetCircleResolution(50); ? ?if(showMode == 0){ ? ? ? ?int num = ani.size(); ? ? ? ?float spaceRatio = 0.8; // 計算間隙占方塊的大小比 ? ? ? ?float rectW = ofGetHeight() / (num + (num + 1) * spaceRatio); ? ? ? ?float space = rectW * spaceRatio; ? ? ? ?int interval = (ofGetHeight() - space) / num; ? ? ? ?ofSetLineWidth(5); ? ? ? ?float startPos = ofGetWidth() * 0.1; ? ? ? ?float endPos = ofGetWidth() - startPos; ? ? ? ?for(int i = 0;i < num;i++){ ? ? ? ? ? ?ofSetColor(myColor); ? ? ? ? ? ?float x = ofMap(ani[i].val,0,1,startPos,endPos); ? ? ? ? ? ?float y = space/2 + (i + 0.5)* interval; ? ? ? ? ? ?ofDrawCircle(x,y,25); ? ? ? ? ? ?ofDrawLine(startPos,y,endPos,y); ? ? ? ?} ? ?}else if(showMode == 1){ ? ? ? ?int num = ani.size(); ? ? ? ?float spaceRatio = 0.4; // 計算間隙占方塊的大小比 ? ? ? ?float rectW = ofGetWidth() / (num + (num + 1) * spaceRatio); ? ? ? ?float space = rectW * spaceRatio; ? ? ? ?float rectY = ofGetHeight() * 0.5; ? ? ? ?int interval = (ofGetWidth() - space) / num; ? ? ? ?for(int i = 0;i < num;i++){ ? ? ? ? ? ?ofPushMatrix(); ? ? ? ? ? ?float x = space/2 + (i + 0.5) * interval; ? ? ? ? ? ?ofTranslate(x, ofGetHeight()/2); ? ? ? ? ? ?ofSetColor(myColor,ofMap(ani[i].val,0,1,255,0)); ? ? ? ? ? ?ofDrawCircle(0,0,rectW/2); ? ? ? ? ? ?ofPopMatrix(); ? ? ? ?} ? ?}else if(showMode == 2){ ? ? ? ?int num = ani.size(); ? ? ? ?float spaceRatio = 0.4; // 計算間隙占方塊的大小比 ? ? ? ?float rectW = ofGetWidth() / (num + (num + 1) * spaceRatio); ? ? ? ?float space = rectW * spaceRatio; ? ? ? ?float rectY = ofGetHeight() * 0.5; ? ? ? ?int interval = (ofGetWidth() - space) / num; ? ? ? ?ofSetLineWidth(4); ? ? ? ?for(int i = 0;i < num;i++){ ? ? ? ? ? ?ofSetColor(myColor); ? ? ? ? ? ?ofPushMatrix(); ? ? ? ? ? ?float x = space/2 + (i + 0.5) * interval; ? ? ? ? ? ?ofTranslate(x, ofGetHeight()/2); ? ? ? ? ? ?ofRotate(ofMap(ani[i].val,0,1,0,180)); ? ? ? ? ? ?ofDrawLine(0,rectW/2,0,-rectW/2); ? ? ? ? ? ?ofDrawCircle(0,rectW/2,30); ? ? ? ? ? ?ofDrawCircle(0,-rectW/2,30); ? ? ? ? ? ?ofPopMatrix(); ? ? ? ?} ? ?}else if(showMode == 3){ ? ? ? ?int num = ani.size(); ? ? ? ?float spaceRatio = 0.4; // 計算間隙占方塊的大小比 ? ? ? ?float rectW = ofGetWidth() / (num + (num + 1) * spaceRatio); ? ? ? ?float space = rectW * spaceRatio; ? ? ? ?float rectY = ofGetHeight() * 0.5; ? ? ? ?int interval = (ofGetWidth() - space) / num; ? ? ? ?for(int i = 0;i < num;i++){ ? ? ? ? ? ?ofPushMatrix(); ? ? ? ? ? ?float x = space/2 + (i + 0.5) * interval; ? ? ? ? ? ?ofTranslate(x, ofGetHeight()/2); ? ? ? ? ? ?ofSetColor(myColor); ? ? ? ? ? ?float w = ofMap(ani[i].val,0,1,0,rectW); ? ? ? ? ? ?ofDrawCircle(0,0,w/2); ? ? ? ? ? ?ofPopMatrix(); ? ? ? ?} ? ?} ? ?ofSetColor(0); ? ?ofDrawBitmapString("ShowMode:" + ofToString(showMode),50,50); } void ofApp::keyPressed(int key){ ? ?if(key == 'r'){ ? ? ? ?for(int i = 0;i < ani.size();i++){ ? ? ? ? ? ?ani[i].start(); ? ? ? ?} ? ?} ? ?if(key == OF_KEY_DOWN){ ? ? ? ?showMode--; ? ? ? ?showMode = MAX(0,showMode); ? ?} ? ?if(key == OF_KEY_UP){ ? ? ? ?showMode++; ? ? ? ?showMode = MIN(3,showMode); ? ?} }
運行效果:
代碼淺析:
按 r 鍵開始動畫,按方向鍵上下切換不用的模式
模塊中只列舉了少數函數曲線,根據個人需要可以拓展補充
End
個人日常中還是傾向于通過自定義函數來使用曲線。如果你不想過于深究各類函數曲線的性質,只希望實現具體的效果。也有辦法可以直接采用別人定制好的各類運動曲線。最后再推薦兩個插件
OF 插件 - ofxAnimatable
下載地址:https://github.com/armadillu/ofxAnimatable
附帶的范例:
Processing 插件 - Ani
在 IDE 的 Libraries 菜單中輸入 “animation”
又或是通過以下鏈接手動下載:
http://www.looksgood.de/libraries/Ani/
:)
∑編輯?|?Gemini
來源 |?InsLab
算法數學之美微信公眾號歡迎賜稿
稿件涉及數學、物理、算法、計算機、編程等相關領域,經采用我們將奉上稿酬。
投稿郵箱:math_alg@163.com
總結
以上是生活随笔為你收集整理的算法动画 - 理解函数曲线的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 加加减减的奥秘——从数学到魔术的思考(二
- 下一篇: 袁亚湘委员:加强对数学等基础科学领域支持