こんにちは。
今回は、processing で「ばね」をつくってみます。
Twitterを眺めていると、こんなツイートが目に入りました。
Friendly reminder that this 17-minute #YouTube video detailing the soft body #physics used in JellyCar Worlds exists. pic.twitter.com/w4SWi8b7fx
— JellyCar dev! (@walaber) 2023年2月5日
「JellyCar」は、簡単に言えばぐにゃぐにゃな車を動かしてゴールを目指すゲームです。
いわゆるソフトボディというものでしょうか。
公式が「JellyCar の物理」と題して紹介してくれているならば、ぜひまねてみたい。
ただ、いきなり JellyCar をつくるのは無理なので、特に重要そうであった「ばね」の処理を触ってみようかなと思いました。
目次
フックの法則
ばねといえばフックの法則でしょう。
$$ F = - k x $$
ここで、$F$ はばねによる力、$k$ はばね定数、$x$ は自然長からの変位を表します。
力 $F$ はばねを引っ張った (押し込んだ) 方向とは逆方向にはたらくので、「$-$」がつきます。
プログラム
ばね1個
一次元
実際にプログラムにする際には、加速度→速度→位置の順に計算していくことになります。
加速度は $F = - k x$ と $ma = F$ から、$\displaystyle a = - \frac{k}{m} x$ と求められます。
そして、速度は加速度を、位置は速度を毎ループ足していくことで計算できます。
まずは重力で上下に振動するばねをつくってみます。
float y, v, a; float m, l, k, g; void setup(){ size(500, 500); y = 400; // おもりの初期位置 m = 10; // おもりの重さ l = 200; // ばねの自然長 k = 0.1; // ばね定数 g = 1; // 重力加速度 } void draw(){ background(255); // ばねの描画 float w = 10 - constrain(y/50, 0, 9); strokeWeight(w); line(250, 0, 250, y); // おもりの描画 strokeWeight(1); ellipse(250, y, 40, 40); // 計算 a = - k * (y - l) / m; // 加速度 v += a; // 速度 v += g; // 重力 y += v; // 位置 }
実行すると、
ばねらしい挙動になっています。
y
の大きさによってばねの太さ w
が変わるようにしたので、伸び縮みしている感じがより伝わる気がします。
(ばねというよりはゴム?)
実際は空気抵抗や摩擦などで振幅がだんだん小さくなっていくと思います (減衰振動)。
v *= 0.95;
を追加すれば、
二次元
では、二次元的に、すなわち $x$ 方向にも振れるようにしてみます。
といっても $y$ 方向での処理と似たものを追加するだけです。
ついでに、マウスで好きなように引っ張る (押し込む) ことができるようにしてみます。
PVector ori, pos, vel, acc; float m, l, k, g; boolean pause = false; void setup(){ size(500, 500); ori = new PVector(250, 0); // ばねの根元の位置 pos = new PVector(300, 400); // おもりの初期位置 vel = new PVector(); acc = new PVector(); m = 10; // おもりの重さ l = 200; // ばねの自然長 k = 0.1; // ばね定数 g = 1; // 重力加速度 } void draw(){ background(255); // ばねの描画 float w = 10 - constrain(PVector.dist(ori, pos)/50, 0, 9); strokeWeight(w); line(ori.x, ori.y, pos.x, pos.y); // おもりの描画 strokeWeight(1); ellipse(pos.x, pos.y, 40, 40); if(pause == false){ // 計算 PVector natural = PVector.sub(pos, ori).setMag(l).add(ori); // 自然長での位置を計算 acc.x = - k * (pos.x - natural.x) / m; // 加速度 (x成分) acc.y = - k * (pos.y - natural.y) / m; // 加速度 (y成分) vel.add(acc); // 速度 // vel.mult(0.99); // 減衰 vel.y += g; // 重力 pos.add(vel); // 位置 } // マウスでおもりの位置を変更 if(mousePressed == true){ pause = true; pos = new PVector(mouseX, mouseY); vel = new PVector(); acc = new PVector(); } else{ pause = false; } }
実行すると、
横にも振れるようになりました。
ばね複数個
続いて、ばねをいくつかつなげてみます。
int n = 4; Weight[] weights = new Weight[n]; void setup(){ size(500, 500); // おもりの位置 weights[0] = new Weight(250, 0); weights[1] = new Weight(200, 100); weights[2] = new Weight(300, 200); weights[3] = new Weight(400, 300); } void draw(){ background(255); // ばねの描画 for(int i = 0; i < n-1; i ++){ float w = 10 - constrain(PVector.dist(weights[i].pos, weights[i+1].pos)/20, 0, 9); strokeWeight(w); line(weights[i].pos.x, weights[i].pos.y, weights[i+1].pos.x, weights[i+1].pos.y); } // おもりの描画 for(int i = 0; i < n; i ++){ weights[i].display(); } // 計算 weights[1].move(weights[0]); weights[1].move(weights[2]); weights[2].move(weights[1]); weights[2].move(weights[3]); weights[3].move(weights[2]); // 減衰、重力 for(int i = 0; i < n; i ++){ weights[i].decay(); weights[i].gravity(); } // マウスでおもりの位置を変更 if(mousePressed == true){ weights[n-1].pos = new PVector(mouseX, mouseY); weights[n-1].vel = new PVector(); weights[n-1].acc = new PVector(); } } class Weight{ PVector pos, vel, acc; float d, m, l, k, g; Weight(float x, float y){ pos = new PVector(x, y); vel = new PVector(); acc = new PVector(); d = 40; // おもりの大きさ m = 10; // おもりの重さ l = 100; // ばねの自然長 k = 0.1; // ばね定数 g = 0.1; // 重力加速度 } void display(){ strokeWeight(1); ellipse(pos.x, pos.y, d, d); } void move(Weight w){ PVector natural = PVector.sub(pos, w.pos).setMag(l).add(w.pos); // 自然長での位置を計算 acc.x = - k * (pos.x - natural.x) / m; // 加速度 (x成分) acc.y = - k * (pos.y - natural.y) / m; // 加速度 (y成分) vel.add(acc); // 速度 vel.mult(0.99); // 減衰 pos.add(vel); // 位置 } void decay(){ vel.mult(0.99); // 減衰 } void gravity(){ vel.y += g; // 重力 } }
コードが簡単になるように、クラス Weight
をつくっておもりの描画とパラメータの計算などを入れておくことにしました。
計算部分が少し煩雑な気もしますが…。
実行すると、
橋っぽくつなげてみると、
端の固定無し
これまではばねの端が固定されていましたが、ここでは自由に動くようにしてみます。
int n = 3; Weight[] weights = new Weight[n]; void setup(){ size(500, 500); // おもりの位置 weights[0] = new Weight(300, 200); weights[1] = new Weight(400, 400); weights[2] = new Weight(200, 400); } void draw(){ background(255); // ばねの描画 for(int i = 0; i < n; i ++){ for(int j = 0; j < n; j ++){ float w = 10 - constrain(PVector.dist(weights[i].pos, weights[j].pos)/20, 0, 9); strokeWeight(w); line(weights[i].pos.x, weights[i].pos.y, weights[j].pos.x, weights[j].pos.y); } } // おもりの描画 for(int i = 0; i < n; i ++){ weights[i].display(); } // 計算 for(int i = 0; i < n; i ++){ for(int j = 0; j < n; j ++){ weights[i].move(weights[j]); } } // 減衰、重力 for(int i = 0; i < n; i ++){ weights[i].decay(); weights[i].gravity(); } // マウスでおもりの位置を変更 if(mousePressed == true){ weights[0].pos = new PVector(mouseX, mouseY); weights[0].vel = new PVector(); weights[0].acc = new PVector(); } } class Weight{ PVector pos, vel, acc; float d, m, l, k; Weight(float x, float y){ pos = new PVector(x, y); vel = new PVector(); acc = new PVector(); d = 40; // おもりの大きさ m = 10; // おもりの重さ l = 100; // ばねの自然長 k = 0.1; // ばね定数 } void display(){ strokeWeight(1); ellipse(pos.x, pos.y, d, d); } void move(Weight w){ PVector natural = PVector.sub(pos, w.pos).setMag(l).add(w.pos); // 自然長での位置を計算 acc.x = - k * (pos.x - natural.x) / m; // 加速度 (x成分) acc.y = - k * (pos.y - natural.y) / m; // 加速度 (y成分) vel.add(acc); // 速度 vel.mult(0.99); // 減衰 pos.add(vel); // 位置 } void decay(){ vel.mult(0.99); // 減衰 } void gravity(){ vel.y += g; // 重力 } }
はたらく力はおもり同士で総当たり的に計算して求めています。
それに伴って、ばねの描画も全てのおもりをつなぐようにしています。
実行すると、
重力や床を追加してみると、
良い感じです。
追記 (2023/03/13)
ここで挙げた例ではそれほど問題ありませんが、いろいろいじっていると、move()
内で vel
や pos
の更新をすると挙動に少し違和感が出るような感じがしました。
(二重ループ内で更新してしまっているので、時間が2倍に進むような感じ?)
そこで、新たに更新用の関数 update()
をつくり、そこで vel
や pos
の値を計算することにしました。
これに伴い、move()
は calc()
に名前を変更し、acc
の値は計算値を足していくことで求めるようにしました。
そして vel
や pos
の計算が終わったところで acc
を初期化するようにしました。
以下、変更後のクラスを載せておきます。
draw()
内も適宜変更してください。
class Weight{ PVector pos, vel, acc; float d, m, l, k, g; Weight(float x, float y){ pos = new PVector(x, y); vel = new PVector(); acc = new PVector(); d = 40; // おもりの大きさ m = 10; // おもりの重さ l = 100; // ばねの自然長 k = 0.1; // ばね定数 g = 0.05; // 重力加速度 } void display(){ strokeWeight(1); ellipse(pos.x, pos.y, d, d); } void calc(Weight w){ PVector natural = PVector.sub(pos, w.pos).setMag(l).add(w.pos); // 自然長での位置を計算 acc.x += - k * (pos.x - natural.x) / m; // 加速度 (x成分) acc.y += - k * (pos.y - natural.y) / m; // 加速度 (y成分) } void decay(){ vel.mult(0.99); // 減衰 } void gravity(){ vel.y += g; // 重力 } void update(){ vel.add(acc); // 速度 pos.add(vel); // 位置 acc.mult(0); // 初期化 } void reflect(){ float e = 0.7; if(pos.x < d/2){ vel.x *= -e; pos.x = d/2; } if(width-d/2 < pos.x){ vel.x *= -e; pos.x = width-d/2; } if(pos.y < d/2){ vel.y *= -e; pos.y = d/2; } if(height-100-d/2 < pos.y){ vel.y *= -e; pos.y = height-100-d/2; } } }
以上
まとめ
以上、processing でばねを扱ってみました。
後半はただばねをつなげただけですが、思ったよりも物理エンジンっぽい動きになりました。
さらに、複数個つくってそれら同士の衝突判定を加えてみるのも良いかと思います。
この辺りはまたどこかで記事にするかもしれません。
最後まで読んでいただいてありがとうございました。
それでは、また次回。