動機
正弦波の組み合わせで矩形波(くけいは)ができることを4歳の娘に視覚的に理解させようと思い、下に示すアニメーションを作ってみました。Rust で。
N=1
N=3
N=10
眺めてるだけでも楽しいですね。娘のウケはイマイチでした。
Piston
Piston は Rust 製のゲームエンジンです。Piston は初めて使うので、Rustでスネークゲームを作る【pistonチュートリアル】 の記事がとても参考になりました。
ウィンドウの表示にはpiston_window
crate を使います。
use piston_window::*;
const FPS: u64 = 60;
fn main() {
let mut window: PistonWindow = WindowSettings::new("Hello, Wave!", [1000, 480])
.exit_on_esc(true)
.samples(4)
.build()
.unwrap();
window.set_ups(FPS);
while let Some(e) = window.next() {
if let Some(_) = e.render_args() {
// 描画処理
}
if let Some(_) = e.update_args() {
// 状態の更新処理
}
}
}
データ構造
正弦波を組み合わせるということは、円盤の端っこに円盤が乗っていて、その円盤の端っこにまた円盤が乗っていて、それぞれがぐるぐると回っているということです。上のアニメーションの青い○が円盤です。なのでそれぞれの円盤は親となる円盤を持っていて、ある円盤自身の中心位置は親円盤の状態に依存していることになります。Rust でこのようなデータ構造を表してみましょう。
失敗例
まずは円盤構造体の定義。
struct Disk {
center: (f64, f64), // 中心位置
radius: f64, // 半径
degree: f64, // 角度
}
続いてApp
構造体を定義します。メンバとしてVec<Disk>
を持たせます。そのVec
に円盤を詰め込んでいって、自身の1個前の円盤が親円盤という状態です。update
メソッドで状態の更新を行います。しかしこれはコンパイルエラー。なぜだか分かりますよね。
struct App {
disks: Vec<Disk>,
}
impl App {
fn update(&mut self) {
for (i, d) in self.disks.iter_mut().enumerate() {
// 自身の円盤角度を進める
d.degree += 1.0;
// 2個目以降の円盤なら
if i > 0 {
// 1個前の円盤(親円盤)の参照を得て...
let parent = self.disks.get(i - 1).unwrap();
// 自身の中心位置を更新
d.center = (
parent.center.0 + parent.radius * (parent.degree / 180.0 * PI).cos(),
parent.center.1 + parent.radius * (parent.degree / 180.0 * PI).sin()
);
}
}
}
}
let disks = vec![];
for _ in 0..N {
let disk = // 省略
disks.push(disk);
}
let mut app = App { disks };
app.update();
成功例
Rust でこういうツリー構造を作るときはRc
とRefCell
を使うのが一般的(?)みたいです。では円盤構造体を書き直します。(Rc
は参照カウント方式のスマートポインタ、RefCell
は"実行時に"借用規則を強制する型なんですって。)親円盤は無い場合もあるのでOption
で包んでおきます。
use std::rc::Rc;
use std::cell::RefCell;
struct Disk {
parent: Option<Rc<RefCell<Disk>>>, // 親円盤への参照
center: (f64, f64), // 中心位置
radius: f64, // 半径
degree: f64, // 角度
}
App
構造体も書き直します。Vec
にはDisk
構造体ではなく、Disk
へのポインタを持たせます。update
メソッドも以下のようになります。これで親円盤の状態によって自身の中心位置を更新できるようになりました。
struct App {
// disks: Vec<Disk>,
disks: Vec<Rc<RefCell<Disk>>>,
}
impl App {
fn update(&mut self) {
for d in self.disks.iter() {
// 自身への可変の参照を得る
let mut disk = d.borrow_mut();
// 自身の円盤角度を進める
disk.degree += 1.0;
// 親円盤があるなら
if let Some(parent) = disk.parent.clone() {
// 親円盤への不変の参照を得る
let parent = parent.borrow();
// 自身の中心位置を更新
disk.center = (
parent.center.0 + parent.radius * (parent.degree / 180.0 * PI).cos(),
parent.center.1 + parent.radius * (parent.degree / 180.0 * PI).sin(),
);
}
}
}
}
let disks = vec![];
// 最初の円盤には親円盤はない
let mut parent = None;
for _ in 0..N {
let disk = Disk {
parent,
// 省略
};
// 円盤へのポインタを作る
let disk_rc = Rc::new(RefCell::new(disk));
// ポインタを複製して Vec に追加
disks.push(disk_rc.clone());
// 親円盤へのポインタ
parent = Some(disk_rc);
}
let mut app = App { disks };
app.update();
描画
App
構造体にrender
メソッドを書きます。実際にはもう少し工夫が必要になりますが簡単のために省略します。是非ご自身で実装を考えてみてください。
impl App {
fn update(&mut self) {
// 省略
}
fn render<E: GenericEvent>(&self, e: &E, window: &mut PistonWindow) {
window.draw_2d(e, |c, g, _| {
clear(color::WHITE, g);
self.disks.iter().for_each(|d| {
let disk = d.borrow();
// 省略
// piston の line 関数とか circle_arc 関数とか使って
// ゴリゴリと描画処理を書いていく
});
});
}
}
main
関数はこんな感じになります。
fn main() {
let mut disks = vec![];
for _ 0..N {
// disks の初期化処理は省略
// 矩形波になるように Disk の半径は (2k-1) ずつ小さくして
// 周波数は (2k-1) ずつ大きくしないといけません
}
let mut app = App { disks };
let mut window: PistonWindow = WindowSettings::new("Hello, Wave!", [1000, 480])
.exit_on_esc(true)
.samples(4)
.build()
.unwrap();
window.set_ups(FPS);
while let Some(e) = window.next() {
if let Some(_) = e.render_args() {
// 描画処理
app.render(&e, &mut window);
}
if let Some(_) = e.update_args() {
// 状態の更新処理
app.update();
}
}
}
まとめ
- Piston を使ってみたよ
-
Rc
、RefCell
の使いどころが分かったよ - 動くものができるとたのしい