Upgrade to Pro — share decks privately, control downloads, hide ads and more …

XFLAG Tech Note vol.02

XFLAG Tech Note vol.02

#技術書典6 に出典されたミクシィグループエンジニア有志による技術書です。

当日の詳細はこちら
https://medium.com/mixi-developers/3c1af2525865

<< 目次 >>
1章:「ドラクリヤ・バレット」の作成事例
2章:XFLAG Academy アプリ
3章:ファイトリーグにおける Unity のシーンアセット最適化
4章:ファイトリーグ環境を OpenAI Gym で構築した話
5章:Elixir と MUCOM88 で構造的な作曲に挑戦

<< TECH NOTE 一覧 >>
mixi tech note #01
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-01

mixi tech note #02
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-02

mixi tech note #03
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-03

mixi tech note #04
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-04

mixi tech note #05
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-05

mixi tech note #06
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-06

mixi tech note #07
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-07

MIXI TECH NOTE #08
https://speakerdeck.com/mixi_engineers/mixi-tech-note-number-08

XFLAG Tech Note Vol.01
https://speakerdeck.com/mixi_engineers/xflag-tech-note-vol-dot-01

XFLAG Tech Note vol.02
https://speakerdeck.com/mixi_engineers/xflag-tech-note-vol-dot-02

MIXI ENGINEERS

April 14, 2019
Tweet

More Decks by MIXI ENGINEERS

Other Decks in Technology

Transcript

  1. まえがき 本書「XFLAG Tech Note vol.02」は、株式会社ミクシィの XFLAG™ スタジオに所属 する有志達によって執筆・制作された技術書です。実際にスマホアプリ「モンスタースト ライク(以下モンスト) 」や「ファイトリーグ」などのプロダクト開発の現場で実践されて

    いる今すぐ試してみたい実⽤的な内容から、個⼈的に研究・実装してみた事など、各⾃が思 い思いに執筆いたしました。そのため、各章それぞれで完結している内容になっています ので、好きな章から好きな順番でお楽しみください。 また、本書は、XFLAG スタジオにある技術的知⾒やアイデアを積極的に共有・公開し ていくことで、よりワクワクドキドキするようなプロダクトが世の中にもっともっと溢れ だすことを願って刊⾏されています。掲載されている情報は、著者⾃⾝の環境で検証し執 筆されたものですので、ご参考にされる際は、ご⾃⾝の責任で判断しご活⽤ください。な お、⽂章表現につきましても、執筆者⾃⾝の⾔葉で伝えたく、フランクな表現となってお りますことご理解いただければと思います。 ディベロッパーリレーションズチーム⼀同 ◆本書に関するお問い合わせ先   https://twitter.com/xflag_engineers  ※ DM にてお問い合わせください。 ◆ XFLAG スタジオについて   https://career.xflag.com/ ※ ʠモンスターストライクʡ 、 ʠモンストʡ 、 ʠファイトリーグʡ 、 ʠFight Leagueʡ 、 ʠXFLAGʡ 、 ʠXFLAG ロゴʡ は、株式会社ミクシィの商標または登録商標です。また、各社の会社名、 サービス及び製品の名称は、それぞれの所有する商標または登録商標です。 i
  2. ⽬次 まえがき i 第 1 章 「ドラクリヤ・バレット」の作成事例 1 1.1 「ドラクリヤ・バレット」の名を知るのは実装完了後

    . . . . . . . . . . . 1 1.2 コードを書くだけが、実装じゃない . . . . . . . . . . . . . . . . . . . . . 2 1.3 ライフルってカッコイイよね . . . . . . . . . . . . . . . . . . . . . . . . 5 1.4 ガトリングもカッコイイよね . . . . . . . . . . . . . . . . . . . . . . . . 9 1.5 読了ありがとうございました . . . . . . . . . . . . . . . . . . . . . . . . 14 第 2 章 XFLAG Academy アプリ 15 2.1 始めに . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 2.2 今回使⽤した環境 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 2.3 アプリの設計と開発 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16 2.4 運⽤と改善 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21 2.5 総括 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 24 3.1 始めに . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 3.2 想定読者 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 3.3 読者が得られる知⾒ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 3.4 Lightmap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 3.5 Mesh 結合 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 3.6 ポリゴンのソート . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 3.7 Index のソート . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43 3.8 他にやれること . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 47 4.1 始めに . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 4.2 背景 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 47 ii
  3. ⽬次 4.3 ファイトリーグのバトルシステム . . . . . . .

    . . . . . . . . . . . . . . . 48 4.4 Gym インタフェース . . . . . . . . . . . . . . . . . . . . . . . . . . . . 49 4.5 実装前の準備 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 50 4.6 実装 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51 4.7 実装後に起きた問題 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58 4.8 まとめ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 61 5.1 はじめに . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 5.2 使⽤する⾔語やアプリ . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 5.3 MML を鳴らす . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 5.4 MML から wav を⽣成する . . . . . . . . . . . . . . . . . . . . . . . . . 63 5.5 Elixir から MML を⽣成する . . . . . . . . . . . . . . . . . . . . . . . . 65 5.6 関数⾔語で⾳楽を表現する . . . . . . . . . . . . . . . . . . . . . . . . . . 66 5.7 ハマったこと . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 5.8 感想とまとめ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70 5.9 成果物の場所 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71 著者紹介 72 iii
  4. 第 1 章 「ドラクリヤ・バレット」の作成事例 本章では、モンストに登場するストライクショット「ドラクリヤ・バレット」の実装事例 を紹介する。実装を担当したクライアントエンジニアである⾓⿓徳(かく たつのり)が 紹介するのだが、読み終わってから後悔することが少なくなるように、先にいくつかの留 意点を記載しておく。 • モンストについて、ある程度プレイしたことのある知識や経験が前提となる

    • 実装を担当したクライアントエンジニアの⽬線のみの話しかない • いろいろな都合で省略されているところがあり、期待している部分がないかもしれ ない • フランクな⽂章である • これはフィクションではない ストライクショットはいわゆる必殺技のようなもので、その仕様は当然ゲーム性と密接 に関わる。そのため、ゲームそのものを知らないと理解が難しいかもしれない。無料で DL できるので実際に遊んでみても良いし、すでに遊んでいる友⼈に読ませても良いし、動画 検索をして閲覧しても良いだろう。 もっと詳しく話を聞きたいな、とか、気になったので質問してみたい、とかがあれば、お そらく巻末に載っているであろう SNS のアカウント宛てに聞いてほしい。 1.1 「ドラクリヤ・バレット」の名を知るのは実装完了後 ユウナが使うストライクショット 今回紹介するストライクショットは「ヴァンパイアの少⼥ ユウナ」が使⽤する「ドラク リヤ・バレット」だ。ユウナは、劇場版第2弾であるソラノカナタに登場した名称通りヴァ ンパイアの少⼥で、銃に変形する棺桶を担いでいる。 1
  5. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.2 コードを書くだけが、実装じゃない 図 1.1: ヴァンパイアの少⼥ ユウナ

    劇中でも幾度か変形して撃つシーンがあり、 「ドラクリヤ・バレット」の仕様もこれを参 考にしている。この変形だが、おもしろいことにガトリング形態とライフル形態の2種類 がある。当初の案件ではこのどちらかだったのだが、制作途中のシーンを⾒たり設定資料 集を確認したり、開発陣営内で仕様を揉んでいる間に「両⽅使えたら良いんじゃね?」と いうことになった。普段なら1キャラ1ストライクショットだが、今回の仕様ではストラ イクショットを2つ作って1キャラが使⽤するため、単純に物量が2倍に増えている。そ のぶん実装にかかる⼯数も増えてしまうのだが、棺桶が2種類の銃形態に変形して撃つと いうロマンの前には些細な問題だ。 1.2 コードを書くだけが、実装じゃない ストライクショット作成の⼤まかな流れは以下の通りで、今回のドラクリヤ・バレット も例にもれない。こまかな違いはあれど、ゲームのメカニクスを実装する際はモンストに 限らず似たようなフローをたどるだろう。 • 企画要件、仕様の確認 • モックアップの作成 • 開発実装 • QA チェック 2
  6. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.2 コードを書くだけが、実装じゃない • 最終確認 企画要件、仕様の確認 仕様は企画の⽅からある程度決められたものが挙がってくる。VFX

    チームも含めて、実 装できるのかどうか、どう実装するのか、などを話し合い、期間内に実装できるよう落と し所を探っていく。本件のストライクショットの仕様を⼀部列挙すると以下の通りだ。 • 速度と攻撃⼒が上がって動き回る • 停⽌時に最初に触れた敵の位置に攻撃 • ガトリングとライフルの振り分けは対象の⽅向で決定 • 対象の敵の HP が 0 になっても同じ位置に撃ち続ける • 弾は貫通する • 属性はキャラに依存する 今回の案件の基本的な挙動はガトリングとライフルに分かれることを覚えておこう。 モックアップの作成 たとえば、ガトリングといえば連続で弾丸を撃ちまくる挙動を思い浮かべるが、この想 像がゲーム内に実装された時に企画側との想定と正しいかどうかの共有をする為にも、あ る程度の出来で画⾯上に表⽰するのが先決だ。想定通りで何も仕様に変更がなければそれ で良いが、違えば仕様の変更も⼗分にある。しっかりと実装するには時間がかかるもので、 その後で仕様が変わると実装のほとんどが無駄になってしまうことも少なくない。 「本当に これで良いのか?」と不安な状態で実装を続けるのも精神的につらいものがあるので、早 い段階で動くものを共有できることは重要だ。 この為にまずは「モックアップ」と呼ばれるものを作る。現場ではよく「モック」と略称 されるもので、コードや素材はテキトーでかまわないのでとりあえず動くものを⾒れる状 態まで作り上げる。いざコーディングをしてみるとその時になって気付くことも多いので、 エンジニア側としても設計がしやすくなる。 問題なければテキトーに書いたコードを消してから書き直したり、そのまま整えて調整 したりする。実装の途中でも気になったことはどんどん詰めていくべきだ。想定はしてい たが記載されていなかった仕様や、まったく想定されていなかった仕様は訊かないと分か らないので、分からないなら確認しておくと経験上良いことにつながりやすい。 決まったことはほかの⼈が触れられる範囲に書きとどめておくことを強く勧める。⼝頭 で話した内容は忘れてしまうもので、数⽇経過してから挙動を⾒直すと「どうしてこんな仕 3
  7. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.2 コードを書くだけが、実装じゃない 様になったんだっけ?」とまったく思い出せず、余計な時間を割くことになるからだ。そ ればかりか数ヵ⽉経過すると実装したのが⾃分かどうかすらも怪しくなってくる。円滑な QA のためにも、不具合の修正を⾏うことになった⾃分以外の⼈のためにも、未来の⾃分の

    ためにも残しておこう。 開発実装 ここまで読んでいる⼈にとって⼀番気になっていると思われるこの項だが、後の節で少 し詳細に話す。モックアップも開発実装の範囲ではあるが明確な線引きがあるわけでもな いので、そのまま本実装に雪崩れ込むことが多い。 QA チェック QA とは「Quality Assurance」の略で直訳すると「品質保証」となる。リリースされた ことを想定して、実装した「ドラクリヤ・バレット」が正しく動くかどうかの確認はもちろ んのこと、モンストをプレイ中に発⽣するさまざまなケースを組み合わせても問題ないか も確認する。ゲームが進⾏不能や強制終了にならなければ良いということはなく、特定の マップでは演出が⾒づらいが良いのかどうか、⾒た⽬は既存の挙動と同じように⾒えるが ダメージの発⽣タイミングが 1 フレーム違うのは良いのかどうか、という細かな指摘もし てくれる。これら QA を専⾨に⾏う QA チームの存在は⾮常に⼤切だ。 開発中に決まった仕様や挙動は申し送り事項として伝えると、QA が円滑に進むので忘 れずにメモしておこう。 最終確認 ⾒事 QA チェックを通過したら、企画や VFX に最終確認をしてもらい、実装完了とな る。実際にユーザーの⼿元で操作されるリリースの⽇を待つだけだ。不具合が出ないこと を祈ろう。 4
  8. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.3 ライフルってカッコイイよね 1.3 ライフルってカッコイイよね 図 1.2:

    ライフル形態 ライフル形態になる条件 ガトリング形態になるか、ライフル形態になるか、これは対象の⽅向で決定するという仕 様だった。どの⽅向へ向いた時、どの形態になるのだろうか。敵との距離が近ければガト リングになり、遠ければライフルになる?  ⾃分の残り HP?  敵にあたった回数?   判定に使おうと思えば使える数値はいくつもあるが、好き勝⼿に決めるわけにはいかな い。企画との仕様確認が必要だ。確認の結果「敵の位置が⾃分より画⾯右側だとライフル、 画⾯左側だとガトリングになる」に決定した。 これは、⾃分から⾒て敵への⽅向ベクトルを求めた後に X 成分を⽐較すれば判定できる。 リスト 1.8: ライフル形態かガトリング形態かの判定をする 1: // myPosition : ⾃分の位置 5
  9. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.3 ライフルってカッコイイよね 2: // enemyPosition :

    敵の位置 3: // toEnemy : 敵⽅向へのベクトル 4: Vector2 toEnemy = enemyPosition - myPosition; 5: if (0.0f <= toEnemy.x) 6: { 7: // ライフル形態! 8: } 9: else 10: { 11: // ガトリング形態! 12: } 上記は擬似コードだが、イメージは伝わっただろうか。シンプルな仕様のおかげで処理 もシンプルにできた。後はそれぞれの弾の挙動を実装していこう。 もしかすると中には「距離が近ければガトリング、遠ければライフルの⽅が良かったの では?」と思う⼈もいるかもしれない。僕もそう考えていた内の⼀⼈だったので、実際に 距離基準の判定を実装して試してみたのだが、どの距離からガトリングになるのかライフ ルになるのか納得感がなかったので却下している。結果だけ⾒れば時間としては無駄だっ たが、実際に試して⽐較するというのは判断がしやすく、当初の案件より良くなる可能性 もあるのでどんどん試していこう。 とはいえ、なんでもかんでも試せるほどの余裕はないので、試すのは「こうした⽅がもっ とおもしろくなるのではっ」と情熱に突き動かされた時にしておこう。 ライフルの弾道挙動 今回ライフルで放たれる弾は、⾼速で貫通する弾が1発だけだ。弾のコリジョンと敵の コリジョンが接触するとダメージが発⽣するのは既存処理に任せられるので、コリジョン の置き⽅さえ気を付ければ良い。 ライフルの先端から画⾯の外に出るまでカンマ数秒しかないので、物理的に速度を持た せて直線運動させず、弾道上にカプセル型のコリジョンを瞬間的に置くようにした。始端 がライフルの先端になるので、キャラからは敵⽅向に向かって少しズラす必要がある。終 端は敵⽅向に向かってズラす量を多くするだけだ。 リスト 1.8: コリジョンの配置を求める 1: // myPosition : ⾃分の位置 2: // enemyPosition : 敵の位置 3: // startPosition : コリジョン始端の位置 4: // endPosition : コリジョン終端の位置 5: Vector2 toEnemy = enemyPosition - myPosition; 6
  10. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.3 ライフルってカッコイイよね 6: Vector2 toEnemyDir =

    toEnemy.Normalize(); 7: 8: const float START_DISTANCE = 50.0f; 9: const float COLLISION_LENGTH = 600.0f; 10: Vector2 startPosition = toEnemyDir * START_DISTANCE + myPosition; 11: Vector2 endPosition = toEnemyDir * COLLISION_LENGTH + startPosition; 始端がどれくらいズレるかはライフルのエフェクト次第となるので、パラメータのよう に const 化している。VFX チーム*1と顔を付き合わせながら調整する際は数値調整で済ま せられるようにしておくと、お互い費やす時間を抑えられて効率が上がる。 弾道と⾔う割には運動挙動がまったくないが、 単純な処理で仕様が実現できるなら単純な 処理で済ませた⽅が分かりやすくて不具合も出にくい。ユーザーはコードを⾒ながらゲー ムをするわけではないので、コードがいかにキレイだったとしても表⽰が変わらないので あればゲームの質は上がっていないのだ。 余談だが、僕も保守性を⾼く保とうとキレイなコードを求めるあまりに、キレイなコー ドを書くことそのものが⽬的になってしまう時がある。適切なコードは仕様によってまっ たく違うものになるので、そんな時はいったん⼿をとめて、落ち着いて⾒直して⾒ると気 付けることが多い。 ライフルの表⽰ 画⾯に表⽰するための素材は VFX チームが同時並⾏して作成している。想定している 表⽰演出を実現するためには、どの部分を VFX 側が担当し、どの部分をコード側が担当す るのか相談する必要がある。 敵の位置によって向きが変わるものに、VFX 側で 360 度分パターンを⽤意してもらうの は効率が悪すぎる。正位置に向けた銃⼝をコード側で回転させてやれば、1 つのパターンで 済ませることができ、⼩数点未満の微妙な⾓度にも対応できる。ライフルはまさしくこの ケースだ。 演出想定はこうだ。 • 初期の棺桶状態は正位置で表⽰する • ライフル状態になった時に敵の⽅向に向ける 正位置から瞬間的に敵の⽅に向けてしまうと違和感が拭えないので、敵の⽅向に向ける ための回転を補間することにした。補間機能は昨今のエンジンやライブラリになら標準で ⽤意されていると思う。もしなければ「Easing」や「Tween」などの単語で検索して、実績 *1 ビジュアルエフェクトを専⾨に作るチーム 7
  11. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.3 ライフルってカッコイイよね あるライブラリの導⼊を検討したり、オープンソースを参考に移植をするなりしよう。 敵への⽅向は 2D ベクトルで求められるので、その値から⾓度を算出しよう。算出する

    には逆正接関数「atan2(y,x)」 *2を使う。atan2(y,x) は、点 (0,0) から点 (x,y) までの半直 線と、正の x 軸の間の平⾯上での⾓度を求めてくれる。返ってくる値はラジアン単位なの で注意しよう。 リスト 1.8: 対象の敵への⾓度を求める 1: // myPosition : ⾃分の位置 2: // enemyPosition : 敵の位置 3: Vector2 toEnemy = enemyPosition - myPosition; 4: Vector2 toEnemyDir = toEnemy.Normalize(); 5: 6: float toEnemyRotation = atan2(toEnemyDir.x, toEnemyDir.y); atan2(y,x) の第⼀引数に x 座標を渡して第⼆引数に y 座標を渡しているのは間違ったわ けではない。x と y を逆にすると、点 (0,0) から点 (x,y) までの半直線と、正の y 軸の間の 平⾯上での⾓度を求めてくれる。また、反時計回りが正⽅向だったのに対し、時計回りが 正⽅向になる。これは 12 時⽅向が正位置の時計回りと⼀致するので、エフェクトの回転値 とも⼀致し、計算がしやすくなる。 敵に向ける⾓度が分かったら、0 度から補間した値をライフルに適⽤していく事で滑らか に敵⽅向に向けてくれる。何秒で向けるか、向ける速さはどれくらいか、などをエフェク トと⾒⽐べたり VFX チームと⼀緒に調整したりすれば完了だ。 *2 環境によっては atan2(x,y) の場合もあるらしい 8
  12. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.4 ガトリングもカッコイイよね 1.4 ガトリングもカッコイイよね 図 1.3:

    ガトリング形態 ガトリング形態になる条件 必ずどちらかに変形することになるので、ライフル形態にならないならガトリング形態 になる。判定処理は「ライフル形態になる条件」の項で説明したのでここでは割愛させて もらいたい。 ⾮常にまれなケースだが、キャラと敵の位置関係がちょうどピッタリ真上や真下などの 場合、x 座標が 0 になるので右側なのか左側なのか判断が付かない。仕様としては、ルール が決まっていればライフルでもガトリングでもどちらでもかまわないとのことだったので、 判定を「<」から「<=」にするだけのライフル形態になるようにした。万が⼀、ライフル もガトリングも撃たずに何もせず進⾏不能になる⽅が恐ろしいので、不明なケースは必ず 確認しておこう。 9
  13. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.4 ガトリングもカッコイイよね ガトリングの弾道挙動 モンストには弾を連続で発射する挙動がすでに存在する。プレイしたことのある⼈なら、 バラージショットガン、ソリッドバレット、オールレンジバレットなどを思い出してもら えれば想像しやすいと思う。既存の処理があるならそれに倣う⽅が良い。

    図 1.4: オールレンジバレット ガトリングもライフルと同じように砲⾝のぶんだけ射出位置をズラす必要がある。算出 ⽅法も「ライフルの弾道挙動」の項と同じということで、気になる⽅はそちらを⾒直して ほしい。 発射された弾の⼀つ⼀つは、発射時の⾓度そのままに直線運動で移動していき、敵を貫通 していく。ガトリングの弾はライフルの弾と違って⽬で追えるほどの速さで動くので、毎 フレーム位置に移動量を⾜し込んで更新していく必要がある。 リスト 1.8: 弾の運動 1: // Vector2 bulletOldPosition : 弾の前回位置 2: // Vector2 bulletPosition : 弾の現在位置 10
  14. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.4 ガトリングもカッコイイよね 3: // Vector2 bulletVelocity

    : 弾の速さ 4: // float deltaTime : 経過時間 5: bulletOldPosition = bulletPosition; 6: bulletPosition += bulletVelocity * deltaTime; deltaTime の乗算を忘れないようにしよう。忘れてしまうと携帯端末のフレーム更新に 依存してしまうので、低スペック端末と⾼スペック端末とで挙動が変わってしまうからだ。 位置に速さを⾜し続けるという素直な⽅法だが、等速で弾が⾶び続ける仕様のためシン プルに収まっている。もし徐々に加速させたければ等加速度直線運動の公式に当てはめる だけで実現できる。等加速度直線運動の公式は覚えているだろうか。覚えていなくても調 べればすぐに分かるので問題ない。僕は⾃分の記憶に⾃信がないので毎回調べている。 リスト 1.8: 等加速度直線運動の公式の⼀つ 1: // v : 速度 2: // v0 : 初速度 3: // a : 加速度 4: // t : 経過時間 5: v = v0 + a * t 算出された速度 v に正規化した⽅向ベクトルを乗算し、位置に⾜し込めば移動させるこ とができる。時間経過によって速度が変わらない場合は加速度 a が 0 になるので、今回の ガトリングの弾と同じく等速になる。 専⽤のヒットエフェクトを表⽰する ガトリングの弾が敵にヒットした時に、劇中と同じようなヒットエフェクトを表⽰しよ う。エフェクトそのものは VFX チームが作成してくれるので、ヒットした箇所でエフェ クトを再⽣するだけで済む。 しかし、ヒットした箇所に同じエフェクトを何度も出すと単調に⾒えてしまう。そこで、 エフェクトの表⽰する位置を、ヒットした箇所を中⼼とした⼀定範囲の円内から決めるの はどうか、と提案してみた。⼩数の乱数を⽣成する機能はライブラリやエンジンに標準機 能としてあることが多いが、円形の範囲から求めてくれるものは多くない。モンストの環 境では⼩数の乱数を求める機能しかないため、⾃作することになった。 当時の僕は、特定の半径の円内から乱数を⽣成する⽅法を知らなかった。ぼんやりと考 えていたのは「円だから極座標を使ったらすぐ求められるんじゃないか」ということだけ だ。極座標は覚えているだろうか。単語さえ聞き覚えがあるなら検索できるので⼤丈夫だ。 11
  15. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.4 ガトリングもカッコイイよね リスト 1.8: 極座標から直交直線座標への変換 1:

    // r : 動径 2: // θ : ⾓度 3: x = r * cos(θ ) 4: y = r * sin(θ ) この式から円内の点を取得できるので、動径 r と⾓度θにそれぞれ成分となる乱数を⽣ 成すればいけるだろうと考えた。 リスト 1.8: 円内から乱数を⽣成する(NG 版) 1: // R : 範囲円の半径 2: // PI : 円周率 3: // r : 動径 4: // theta : ⾓度 5: // rand() : 乱数⽣成関数 [0.0f ~ 1.0f] 6: // hitPosition : ヒットした位置 7: float r = rand() * R; 8: float theta = rand() * (2 * PI); 9: 10: float x = r * cos(theta); 11: float y = r * sin(theta); 12: Vector2 effectPosition = Vector2(x, y) + hitPosition; よーしできたできた。弾が敵にヒットしたら上記の式から位置を算出し、エフェクトを 再⽣すれば良い。 だが、これでは不⼗分だった。何度もヒットエフェクトを⾒ている内にだんだんと違和 感を持ち始めていたのだが、どうも円の中⼼付近に表⽰されやすい気がしていた。実際に 上記の式で数千回プロットしてみると、中⼼付近に点が集中することが⾒て分かる。半径 4 の円で試した結果が次の図だ。 12
  16. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.4 ガトリングもカッコイイよね 図 1.5: 中央に集中してしまっている どうすれば良いだろうか。結果を先に伝えると、動径

    r には円の半径を 2 乗して平⽅根 を取ったものにすれば良い。 リスト 1.8: ヤコビアンを加味する 1: // sqrt() : 平⽅根を求める関数 2: float r = sqrt( rand() * R * R ); 図 1.6: ⼀様に分布するようになった なぜそうなるかの説明には⼤学数学のヤコビアン周りが絡んでくるので、気になる⽅は 調べてほしい。正直なところ付け焼き刃の僕の知識では誤解を与える可能性が⾮常に⾼い。 より詳細により分かりやすく説明してくれる⼈がいるインターネットの情報を閲覧した⽅ はるかに信憑性が⾼いだろう。 13
  17. 第 1 章 「ドラクリヤ・バレット」の作成事例 1.5 読了ありがとうございました なお、根号計算が速度的に気になったので、精度を若⼲落としつつも⾼速な根号計算に 交換することも考えていた。実際に試したところ処理負荷は問題なさそうだったので根号 計算はそのままに、低スペック端末でも問題ないか QA

    の申し送り事項として⾒てもらう ことになった。 実装完了、QA へ 仕様は満たし、表⽰演出関連の実装も済んだなら、最後に企画側に確認を取って問題な いか確かめよう。問題なければ QA をしてもらい、不具合がないかチェックしてもらう。 何事もないと良いのだが、悲しいかなたいていの場合は何かが起こる。起こったことは共 有し、修正対応の⽅向性を相談しておくと、今後似たような不具合を解決するまでの時間 を短くできるので、⾃分を含め未来の誰かを助けることにつながる。 ⼀度実装した後、余程のことがない限りコードの変更は⾏わなくなるので、変な処理や コードがないか整理しておくと安⼼だ。⼤規模、⻑期間のモンストでは特に響きやすいの でより意識しておきたいことでもある。 QA がすべて通ったなら、後はリリースされるのを待つだけだ。不思議な事に、リリース してユーザーの⼿元で動くようになって初めて顕現する不具合もあるので油断ならない。 1.5 読了ありがとうございました 今回は開発実装時の内容を厚めにしてみたが、いかがだっただろうか。同じコードでも 仕様によって良い悪いが変わるのでしっかり確認しないといけないし、仕様を実現するた めに未知の問題に直⾯することも数多い。⽴ち向かっているときは⾮常につらいものだが、 それらを乗り越えて作り上げられたものは振り返ってみても「よくやった」と当時の⾃分 を褒めたくなる。次にユウナのストライクショットを⾒る際は、以前と違って少し⾒⽅が 変わっているかもしれない。 ちなみに、ほかにもいろいろと試⾏錯誤してクオリティを上げようとした事例はあるの だが、そう⻑々と書けないので以下に⼀部を列挙するだけにとどめることを許してほしい。 • 銃⼝の内側に⼩さい敵がいた場合、現実では当たらないがゲーム上では使いやすさ を優先し当たるように処理した • ヒットエフェクトは出しすぎると負荷が⾼くなってしまうので、⼀定数以上は出な いように間引いている • ガトリング中は銃⾝部分が回転しているように⾒せたいと提案し、 ⽤意してもらった • 銃⾝部分がスピンアップしてから弾が出る⽅がよりガトリングらしいので、何度か 調整した 14
  18. 第 2 章 XFLAG Academy アプリ 2.1 始めに XFLAG では

    CSR 活動 (社会貢献活動) の⼀環として、中⾼⽣の企業訪問 (職業研究) を 受け付けています。学⽣は⽇本全国から訪れます。特に東京から離れた地域の学校は修学 旅⾏のカリキュラムの⼀環としてミクシィを訪れています。企業訪問イベント開始当初は 座学のみで⾏われ、会社や職業の紹介が主体でした。せっかくの修学旅⾏で訪れた企業訪 問が座学だけでは楽しくないだろうと考え、学⽣の良い想い出に残してもらえるようなコ ンテンツを作ろうと考えました。さらに、コンテンツの中で制作の仕事を少しでも体験し てもらい、将来「IT、ゲーム関連の仕事がしたい!」と思ってもらえるようにしました。そ の結果⽣み出したコンテンツがモンスト IP をベースとしたゲーム制作が体験できるスマー トフォンアプリ「XFLAG Academy アプリ」です。ここではアプリの実装内容を紹介しよ うと思います。 図: アカデミーアプリ 15
  19. 第 2 章 XFLAG Academy アプリ 2.2 今回使⽤した環境 想定読者 •

    社会貢献活動に関⼼のある⽅ • 教育関連事業に関⼼のある⽅ 2.2 今回使⽤した環境 Unity(C#) を利⽤してコンテンツを制作しました。 Unity モンストは Cocos2dx で制作されています。Unity を⽤いた理由としては、Cocos2dx よ りも Unity の⽅が実現したい内容に向いていたためです。詳細は後述します。 2.3 アプリの設計と開発 アプリの要求定義 アプリを設計するにあたり、以下の条件から内容を考えました。1. 体験を通して将来 IT 業界で働きたいと思ってもらえるものにする。2. モンストが好きで XFLAG を訪問先に選 んでいるのでモンストの IP を利⽤する。3. ただモンストと同じ内容を実現しただけでは 遊びに来ただけになってしまうので、勉強要素を取り⼊れる。4. 企業訪問イベントは時間 が限られており、アプリを触れるために与えられた時間は約 20 分。5. 本業務との合間に 制作をする必要があり時間が多く取れない。6. すぐに作れて⼿軽にメンテナンスできるも のにする。 考察〜私が受けてきた学校教育から感じる課題〜 勉強要素で何を取り⼊れようか考えた時、⽬を付けたのは「数学」です。国語や英語は将 来どう役に⽴つのかは⾔わずもがなです。⼀⽅数学は簡単な計算は社会⼈になっても必要 なのは誰の⽬から⾒ても明らかですが、たとえば「座標・⾓度・三⾓関数などが将来どう役 に⽴つのか?」は特に中学1・2年の学⽣には伝わっていないのではないかという疑問が あります。私も中学⽣になったときは、座標・三⾓関数などが将来何に役⽴つかは教師から 16
  20. 第 2 章 XFLAG Academy アプリ 2.3 アプリの設計と開発 説明のないまま、ただ教科書に従ってやっていただけでした。私が将来数学が重要だと気 付くようになったのは⾃分でアプリやゲームを作り始めるようになった中学3年⽣くらい

    のときです。⾃⾝で気付けたのは幸いでしたが、なぜ学校の先⽣は実⽤的に教えてくれな かったのだろうかという疑問が残りました。そして今、エンジニアの⽴場として学⽣を迎 えるにあたり、アプリやゲームには数学が多⽤されていることを伝え、授業でやっているこ との意味を伝えられたらと考えました。学校での授業はけっして無駄なものではない。と。 設計 数学を盛り込みモンストの IP を活⽤したアプリを作る上で、実現しようと思ったもの は、モンストの⼀部を制作して遊べるアプリです。いわゆるツクールソフトであれば、ゲー ム制作を疑似体験でき、モンストのレーザーやホーミングミサイルなどの攻撃を⾃分で作 れれば数学要素を⼊れることができます。企業訪問イベントは約 20 分と限られた時間であ ることから、Stratch のような擬似プログラミングまでは実現できないため、数式を埋めて 攻撃を作ることとしました。また、本業務の合間で簡単に実現するため、レベルデザイン ツールがもともと備わっている Unity を選択しました。ステージ、エフェクト、サウンド、 キャラクタイラストなどのリソースはモンストのものを Unity で扱いやすいように加⼯し て利⽤することにしました。⼿軽にレベルデザインができる Unity で Try&Error を早く 回し、⼿軽なモンストツクールを実現し、学⽣さんに早く提供をする。これをテーマに制 作を開始しました。 開発 まずはステージ・プレイヤー・敵を選択して指で好きな位置に置き、各種パラメータを設 定できる制作モードを実現しました。これと同時に配置や設定を⾏った状態から、⾃分で 作ったゲームを引っ張ってあそべるプレイモードを実現しました。ここまではモンストツ クールに相当する機能です。 17
  21. 第 2 章 XFLAG Academy アプリ 2.3 アプリの設計と開発 図: 制作モードとプレイモード

    ここからが⼤事な勉強要素の実現です。中学⽣が学校でならう数学の範囲でアプリに活 かしやすいものは「座標・⾓度」です。これをモンストの攻撃でわかりやすく教えるとした ら拡散弾がふさわしい内容だと考えました。拡散弾は指定した⾓度毎に弾を散らして発射 します。学⽣には⾓度や弾数を⾃由に決めてオリジナルの拡散弾が作れる機能を作りまし た。モンスト本編にはない特別な拡散弾ですので、⼈気キャラのオラゴンを使ってオラ様 拡散弾と命名しました。 18
  22. 第 2 章 XFLAG Academy アプリ 2.3 アプリの設計と開発 図: オラ様拡散弾

    次に⾼校⽣です。⾼校⽣くらいになると、数学で「三⾓関数」が登場します。これをモン ストの攻撃でわかりやすく教えるとしたらエナジーサークルがふさわしい内容だと考えま した。エナジーサークルはキャラクタの周囲で円の形を作るレーザー砲です。学⽣にはど の三⾓関数を使えば円状のレーザーが作れるのか選ぶ機能を作りました。こちらもモンス ト本編にはない特別なエナジーサークルですので、⼈気キャラのぜつぼうくんを使ってぜ つぼうエナサーと命名しました。 19
  23. 第 2 章 XFLAG Academy アプリ 2.3 アプリの設計と開発 図: ぜつぼうエナサー

    早く学⽣に届けたいと考えたため、 ⼀部特殊な作り⽅をしています。Cocos2dx(C++) の 攻撃プログラムの⼀部を Unity ⽤に C#移植をしているという点です。攻撃のグラフィッ ク素材 (Effect)・サウンド素材 (SE)・スクリプトは1つの Prefab に⼊れ、Instantiate で ⽣成したものが発動されています。これをコントロールする外側のスクリプトは C#でゼ ロから書いていますが、攻撃内のスクリプトは Cocos2dx で書かれたソースに⼿を⼊れて 利⽤しています。たとえば円を描くエナジーサークルのプログラムであれば以下のような 感じです。C++ ベースなので C#っぽい書き⽅ではないですね。 C++ から C#への移植例 private GameObject _fromCharacterObject; private float _speed; private float _radius; private float _time; 20
  24. 第 2 章 XFLAG Academy アプリ 2.4 運⽤と改善 void Update

    () { float x = Mathf.Cos(_time * _speed) * _radius; float y = Mathf.Sin(_time * _speed) * _radius; x += _fromCharacterObject.transform.position.x; y += _fromCharacterObject.transform.position.y; this.transform.position = new Vector3(x, y, 0.0f); } ⼀部このような作り⽅をしたことで制作時間は⼤幅に短縮でき、構想から実現まで短期 間で⾏うことができました。 2.4 運⽤と改善 運⽤ XFLAG Academy アプリは学⽣に好評なコンテンツとなりました。企業訪問イベント の最後にアンケートをお願いしているのですが、定量的な評価としてほぼ満点の評価をい ただきました。定性的な評価としては「数学の授業がんばります」 「将来ゲーム製作者にな りたい」 「IT 企業ではたらきたい」といううれしいフィードバックを多くいただくことがで きました。⼀⽅、課題も⾒えてきました。数学に紐づいたコンテンツとしたため、エンジ ニア以外を志望している学⽣にはわかりづらいという声です。 改善 エンジニア向け数学要素だけではなく、デザイン要素を追加することにしました。やは り 20 分という時間制限があるため、ゼロからキャラクタデザインを興す訳にもいきませ ん。そこで追加したものはボスのペイント機能です。ベースとなる⾊はこちらから提供し、 ⾊の組み合わせを⾃由に⾏ってデザイン感を出すようにしました。 21
  25. 第 2 章 XFLAG Academy アプリ 2.5 総括 図: ボスデザイン機能

    2.5 総括 企業訪問イベントの今後 XFLAG Academy アプリが登場して約2年の運⽤がなされてきました。年間数百の学 ⽣に体験を提供でき、今後に活かせる何かを持って帰ってもらえたことはうれしい限りで す。また、学⽣に職業紹介やアプリの使い⽅や数学を教えることで、⾃⾝の学びにもなり ました。特に参考になったのは UI 設計です。私⾃⾝、仕事でゲームを作っていますし、プ ライベートでも数本ゲームやアプリの配信を⾏っています。エンドユーザーの操作に関す る疑問や改善提案はたいへん貴重なものでした。 今後の XFLAG Academy での取り組みについては、活動⽅法などの⾒直しが⼊ること 22
  26. 第 2 章 XFLAG Academy アプリ 2.5 総括 になりました。CSR アプリとしては⼀定の評価をいただけたアプリですので、今後何らか

    の形でソースの公開や学⽣のフィードバックを公開できたら良いなと考えています。 23
  27. 第 3 章 ファイトリーグにおける Unity のシーン アセット最適化 3.1 始めに Unity

    上ではたくさんの GameObject を配置できますが、多ければ多いほど処理負荷 につながります。背景などは、ほとんど動かない GameObject が⼤量に配置されており、 CPU リソースを消費させるには、もったいない時も多くあります。ほかにも、アセット データには、デザイナーさんには、コントロールの難しい問題が多く潜んでいます。それ らをエンジニアリングで解決することは、佳境のプロジェクトにとって、急務となること があります。 本章では、弊社タイトルファイトリーグのケースをもとに、事前処理でのアセットの問 題解決法を共有します。 3.2 想定読者 アセットの問題を技術的に解決したい⼈ 3.3 読者が得られる知⾒ • Mesh 結合アルゴリズム • シーン Mesh の操作時のハマりポイント • 静的なデータへのアプローチ 24
  28. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.4 Lightmap 3.4 Lightmap

    Lightmap とは ライトをテクスチャに焼き込むことで、ランタイムのライト計算を減らすテクニックで す。ファイトリーグでは、Unity 内に搭載されている Lightmap を使⽤しています。Unity 公式に使⽤⽅法をまとめられているため、詳しくは割愛します。 3.5 Mesh 結合 Mesh 結合の必要性 Unity には Static Batching というレンダリングシステムが搭載されています。Static Batching を設定することにより、 複数の Mesh を 1 ドローで描画できます。しかし、 Static Batching は、Mesh を⼀つにまとめますが、GameObject はバラバラのまま⽣きています。 GameObject 毎、結合したうえで、Static Batch すれば、GameObject 量に由来するコス トが削減できます。 ⼿法選択 Mesh の結合といえば、Unity には Mesh.CombineMeshes という関数が⽤意されていま す。しかし、この関数には⽋点があります。SubMesh を複数持つ Mesh を結合する際、⼀ 度 SubMesh ごとに分解しなければなりません。その際、SubMesh 間で共有されている頂 点が複製され、トータルの頂点数が増えてしまいます。ファイトリーグでは Mesh 結合を ⾃作し、Material を元に SubMesh の振り分けに対応しています。 対象の選定 まずは、今回結合したいシーンを⾒てみましょう。これはファイトリーグで実際に使⽤ しているアセットです。 25
  29. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 図

    3.1: 結合したいシーン 結合するにあたり、どういったメッシュを結合対象とするか決めなければなりません。 タグ付け等の⼿段であれば⾃由度があります。ファイトリーグでは作業⼿順の増加を防ぐ ために、下記のルールにのっとって⾃動収集します。 • MeshRenderer を持つ • Component の参照を受けない • Component の参照を受ける GameObject/Component の⼦でない • Animation の参照を受けない • Animation の参照を受ける GameObject の⼦でない • 除外リストに含まれない レンダラの収集 // シーン内のレンダラを収集する var targetRenderers = new List<MeshRenderer>(); ComponentUtils.GetComponentsInScenes(targetRenderers); // 結合しないレンダラを対象外にする targetRenderers.RemoveAll(excludeRenderers.Contains); 26
  30. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 //

    どこかから参照されているオブジェクトを対象外にする this.RemoveReferencedComponents(targetRenderers); // @brief リスト内から、どこかから参照されているコンポーネントを削除する // @tparam T コンポーネント型 // @param components コンポーネント private void RemoveReferencedComponents<T>(List<T> components) where T : Component { var referencedObjects = new List<GameObject>(); GameObjectUtils.FindPropertyReferencedObjectsInScenes(referencedObjects); GameObjectUtils.FindAnimationReferencedObjectsInScenes(referencedObjects); components.RemoveAll((component) => GameObjectUtils.IsChildOf(component.gameObject, referencedObjects)); } GameObjectUtils.cs // @class GameObjectUtils // @brief ゲームオブジェクトのユーティリティ public static class GameObjectUtils { // @brief シリアライズプロパティから参照されているオブジェクトを探す // @param result シリアライズプロパティから参照されているオブジェクト public static void FindPropertyReferencedObjectsInScenes( List<GameObject> result) { if (result == null) return; var components = new List<Component>(); ComponentUtils.GetComponentsInScenes(components); foreach (var component in components) { SerializedObject serializedObject = new SerializedObject(component); SerializedProperty property = serializedObject.GetIterator(); while (property.Next(property.name != "m_Children")) { if (property.propertyType != SerializedPropertyType.ObjectReference || property.name == "m_GameObject" || property.name == "m_Father" || property.name == "m_Children") continue; // オブジェクト参照プロパティを取得 var propertyObject = property.objectReferenceValue; // コンポーネント参照を取得 var referencedComponent = propertyObject as Component; 27
  31. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 if

    (referencedComponent != null && !result.Contains(referencedComponent.gameObject)) { result.Add(referencedComponent.gameObject); continue; } // ゲームオブジェクト直参照の可能性もあるので取得 var gameObject = propertyObject as GameObject; if (gameObject != null && !result.Contains(gameObject)) { result.Add(gameObject); continue; } } } } // @brief アニメーションから参照されているオブジェクトを探す // @param result アニメーションから参照されているオブジェクト public static void FindAnimationReferencedObjectsInScenes( List<GameObject> result) { if (result == null) return; // Animator コンポーネントの参照 var allAnimators = new List<Animator>(); ComponentUtils.GetComponentsInScenes(allAnimators); foreach (var animator in allAnimators) { if (animator == null) continue; var runtimeAnimatorController = animator.runtimeAnimatorController; if (runtimeAnimatorController == null) continue; var clips = runtimeAnimatorController.animationClips; if (clips == null) continue; foreach (var clip in clips) { FindAnimationClipReferencedObjects(clip, animator.transform, result); } } // Animation コンポーネントの参照 var allAnimations = new List<Animation>(); ComponentUtils.GetComponentsInScenes(allAnimations); foreach (var animation in allAnimations) { if (animation == null) continue; 28
  32. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 foreach

    (AnimationState animationState in animation) { if (animationState == null) continue; FindAnimationClipReferencedObjects( animationState.clip, animation.transform, result); } } } // @brief アニメーションクリップから参照されているオブジェクトを取得 // @param clip アニメーションクリップ // @param rootTransform ルートのトランスフォーム // @param result 結果 public static void FindAnimationClipReferencedObjects( AnimationClip clip, Transform rootTransform, List<GameObject> result) { if (clip == null || result == null) return; var curveBindings = AnimationUtility.GetCurveBindings(clip); foreach (var curveBinding in curveBindings) { var transform = rootTransform.Find(curveBinding.path); if (transform == null || result.Contains(transform.gameObject)) continue result.Add(transform.gameObject); } } } ComponentUtils.cs // @class ComponentUtils // @brief コンポーネントのユーティリティ public static class ComponentUtils { // @brief 複数のシーン内のコンポーネントを取得する // @tparam T コンポーネント型 // @param result コンポーネント⼀覧 // @param includeInactive アクティブなオブジェクトを含むか public static void GetComponentsInScenes<T>( List<T> result, bool includeInactive = false) { for (int i = 0; i < SceneManager.sceneCount; ++i) { var scene = SceneManager.GetSceneAt(i); GetComponentsInScene(scene, result, includeInactive); } 29
  33. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 }

    // @brief シーン内のコンポーネントを取得する // @tparam T コンポーネント型 // @param scene シーン // @param result コンポーネント⼀覧 // @param includeInactive アクティブなオブジェクトを含むか public static void GetComponentsInScene<T>( Scene scene, List<T> result, bool includeInactive = false) { var rootGameObjects = scene.GetRootGameObjects(); foreach (GameObject rootGameObject in rootGameObjects) { if (rootGameObject == null) continue; var children = rootGameObject.GetComponentsInChildren<T>(includeInactive); result.AddRange(children); } } } 結合単位をまとめる いざ、結合をしてみようとすると、 「MeshRenderer の Property の異なるものどうし」 「Static フラグ、Layer の異なるものどうし」など結合できない GameObject があることが わかります。GameObject、MeshRenderer 毎に設定されパラメータは複数の持つことが できない為、1 つの GameObject にできません。 そこで、GameObject と MeshRenderer のパラメータをもとに結合単位を決めます。結 合単位の基準にするのは下記パラメータです。 • MeshRenderer.sortingOrder • MeshRenderer.sortingLayerID • MeshRenderer.sortingLayerName • MeshRenderer.reflectionProbeUsage • MeshRenderer.probeAnchor • MeshRenderer.lightProbeProxyVolumeOverride • MeshRenderer.lightProbeUsage • MeshRenderer.LightmapIndex • MeshRenderer.receiveShadows • MeshRenderer.shadowCastingMode 30
  34. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 •

    GameObject.layer • GameObject の Static フラグ (GameObjectUtility.GetStaticEditorFlags によって 取得) ファイトリーグでは、これらのパラメータをキーに、MeshRenderer の List を Value に し、Dictionary 形式で管理することで、簡単に結合単位を分けることができます。 Dictionary のキー // @struct MeshRendererParameter // @brief メッシュレンダラのパラメータ private struct MeshRendererParameter : IEquatable<MeshRendererParameter> { private int sortingOrder; //!< ソート順 private int sortingLayerID; //!< ソートレイヤー ID private string sortingLayerName; //!< ソートレイヤー名 private ReflectionProbeUsage reflectionProbeUsage; //!< ReflectionProbe 使⽤オ プション private Transform probeAnchor; //!< Probe ⽤アンカーポジションオブジェクト private GameObject lightProbeProxyVolumeOverride; //!< LightProbe のボリューム 値上書き⽤オブジェクト private LightProbeUsage lightProbeUsage; //!< LightProbe 使⽤オプション private int lightmapIndex; //!< LightMap のインデックス private bool receiveShadows; //!< 影を受けるか private ShadowCastingMode shadowCastingMode; //!< 影を落とすためのモード private StaticEditorFlags staticMask; //!< StaticFlag のマスク private int layer; //!< レイヤー // @brief ⽐較 // @param obj ⽐較対象 // @return 結果 public int CompareTo(object obj) { return this.ToString().CompareTo(obj.ToString()); } // @brief ⽂字列変換 // @return ⽂字列 public override string ToString() { return "sortingOrder:" + this.sortingOrder + ", sortingLayerID:" + this.sortingLayerID + ", sortingLayerName:" + this.sortingLayerName + ", reflectionProbeUsage:" + this.reflectionProbeUsage + ", probeAnchor:" + this.probeAnchor + ", lightProbeProxyVolumeOverride:" + this.lightProbeProxyVolumeOverride + ", lightProbeUsage:" + this.lightProbeUsage + ", lightmapIndex:" + this.lightmapIndex + ", receiveShadows:" + this.receiveShadows + ", shadowCastingMode:" + this.shadowCastingMode + ", staticMask:" + this.staticMask + 31
  35. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 ",

    layer:" + this.layer; } // @brief 対象にコピーする // @param renderer レンダラ public void CopyTo(MeshRenderer renderer) { renderer.sortingOrder = this.sortingOrder; renderer.sortingLayerID = this.sortingLayerID; renderer.sortingLayerName = this.sortingLayerName; renderer.reflectionProbeUsage = this.reflectionProbeUsage; renderer.probeAnchor = this.probeAnchor; renderer.lightProbeProxyVolumeOverride = this.lightProbeProxyVolumeOverride; renderer.lightProbeUsage = this.lightProbeUsage; renderer.lightmapIndex = this.lightmapIndex; renderer.receiveShadows = this.receiveShadows; renderer.shadowCastingMode = this.shadowCastingMode; GameObjectUtility.SetStaticEditorFlags( renderer.gameObject, this.staticMask); renderer.gameObject.layer = this.layer; } // @brief 対象からコピーする // @param renderer レンダラ public void CopyFrom(MeshRenderer renderer) { this.sortingOrder = renderer.sortingOrder; this.sortingLayerID = renderer.sortingLayerID; this.sortingLayerName = renderer.sortingLayerName; this.reflectionProbeUsage = renderer.reflectionProbeUsage; this.probeAnchor = renderer.probeAnchor; this.lightProbeProxyVolumeOverride = renderer.lightProbeProxyVolumeOverride; this.lightProbeUsage = renderer.lightProbeUsage; this.lightmapIndex = renderer.lightmapIndex; this.receiveShadows = renderer.receiveShadows; this.shadowCastingMode = renderer.shadowCastingMode; this.staticMask = GameObjectUtility.GetStaticEditorFlags( renderer.gameObject); this.layer = renderer.gameObject.layer; } // @brief ハッシュコードの取得 // @return ハッシュコード public override int GetHashCode() { return this.ToString().GetHashCode(); } // @brief ⽐較 // @param other ⽐較オブジェクト // @retval true ⼀致 // @retval false 不⼀致 32
  36. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 public

    bool Equals(MeshRendererParameter other) { return this.ToString() == other.ToString(); } } Dictionary のバリュー // @class CombineUnitList // @brief 結合単位⼀覧 private class CombineUnitList { //! 結合レンダラ⼀覧 public List<CombineUnit> CombineUnits { get { return this.combineUnits; } } //! 結合レンダラ⼀覧 private List<CombineUnit> combineUnits = new List<CombineUnit>(); // @brief レンダラの追加 // @param renderer レンダラ // @retval true 追加成功 // @retval false 追加失敗 public bool Add(MeshRenderer renderer) { foreach (CombineUnit combineUnit in this.combineUnits) { if (combineUnit.Add(renderer)) return true; } var newUnit = new CombineUnit(); if (!newUnit.Add(renderer)) return false; this.combineUnits.Add(newUnit); return true; } } // @class CombineUnit // @brief 結合単位 private class CombineUnit { //! 結合レンダラ⼀覧 public List<MeshRenderer> Renderers { get { return this.renderers; } } //! 頂点数 private int totalVertexCount = 0; //! 結合レンダラ⼀覧 private List<MeshRenderer> renderers = new List<MeshRenderer>(); 33
  37. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 //

    @brief レンダラの追加 // @param renderer レンダラ // @return true = 追加成功 public bool Add(MeshRenderer renderer) { if (renderer == null) return false; var filter = renderer.GetComponent<MeshFilter>(); if (filter == null) return false; var mesh = filter.sharedMesh; if (mesh == null) return false; // note: 32bit インデックス未対応 if (ushort.MaxValue - this.totalVertexCount < mesh.vertexCount)return false; // 追加 this.totalVertexCount += mesh.vertexCount; this.renderers.Add(renderer); return true; } } 結合してみる 結合の準備が整いました。順番にメッシュを結合していきます。結合時に操作するパラ メータは下記です。それぞれ操作時の注意点があります。 • Mesh.vertices – Transform.MultiplyPoint を⾏い、位置を動かします。 • Mesh.normals • Mesh.tangents • Mesh.uv • Mesh.uv3 • Mesh.uv4 • Mesh.colors – 先頭から順に結合していきます。ただし、もともと値がない場合に結合をする と、エラーになります。 「vertices と同じ⻑さ」or「⻑さなし」でしか作ること はできません。 • Mesh.uv2 – Lightmap ⽤の UV となっています。Lightmap の UV の Scale と Offset は 34
  38. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 MeshRenderer

    によって異なります。結合前に正規化を⾏う必要があります。 MeshRenderer.LightmapScaleOffset の x, y が Scale 値、z, w が Offset 値で す。uv2 を x,y で Scale し、z,w を⾜すことで、正規化できます。 • Mesh.triangles – ポリゴンの Index 配列です。Mesh.triangles は SubMesh 毎にあるため、 Mesh.GetTriangles を⽤いて SubMesh ごとに取得します。結合時は「Index は結合するごとにずれる」という点に注意する必要があります。 すべて配列ですので、List に変換し、AddRange するだけで結合できます。triangles、 vertices、uv2 は例外です。 結合処理 // @brief メッシュ結合 // @param combineUnit 結合単位 // @param combinedMesh 結合済みメッシュ // @param combinedMaterials 結合済みマテリアル private void CombineMeshes( CombineUnit combineUnit, out Mesh combinedMesh, out Material[] combinedMaterials) { // 各種パラメータの作成 var vertices = new List<Vector3>(); var normals = new List<Vector3>(); var tangents = new List<Vector4>(); var uv = new List<Vector2>(); var uv2 = new List<Vector2>(); var uv3 = new List<Vector2>(); var uv4 = new List<Vector2>(); var colors = new List<Color>(); var triangles = new List<List<int>>(); // マテリアルを取得し、サブメッシュ作成準備 List<MeshRenderer> renderers = combineUnit.Renderers; var newMaterials = new List<Material>(); var materialToSubMeshIndex = new Dictionary<Material, int>(); foreach (MeshRenderer renderer in renderers) { if (renderer == null) continue; Material[] materials = renderer.sharedMaterials; foreach (Material material in materials) { if (material == null || materialToSubMeshIndex.ContainsKey(material)) continue; triangles.Add(new List<int>()); newMaterials.Add(material); materialToSubMeshIndex.Add(material, materialToSubMeshIndex.Count); 35
  39. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 }

    } // メッシュの結合を開始 foreach (MeshRenderer renderer in renderers) { if (renderer == null) continue; var filter = renderer.GetComponent<MeshFilter>(); if (filter == null) continue; int indexOffset = vertices.Count; Mesh mesh = filter.sharedMesh; if (mesh == null) continue; var scale = renderer.transform.lossyScale; bool isReverse = scale.x * scale.y * scale.z < 0; Matrix4x4 transform = renderer.transform.localToWorldMatrix; // 頂点の結合 var baseVertices = mesh.vertices; foreach (var baseVertex in baseVertices) { vertices.Add(transform.MultiplyPoint(baseVertex)); } // ライトマップ⽤の UV 作成 var lightmapScaleOffset = renderer.lightmapScaleOffset; var lightmapScale = new Vector2(lightmapScaleOffset.x, lightmapScaleOffset.y); var lightmapOffset = new Vector2(lightmapScaleOffset.z, lightmapScaleOffset.w); var lightmapUvs = mesh.uv2; for (int i = 0; i < lightmapUvs.Length; ++i) { lightmapUvs[i].Scale(lightmapScale); lightmapUvs[i] += lightmapOffset; } // 頂点サイズに依存するパラメータの結合 this.AddRangeOfSize( normals, mesh.normals, vertices.Count, Vector3.zero); this.AddRangeOfSize( tangents, mesh.tangents, vertices.Count, Vector3.zero); this.AddRangeOfSize(uv, mesh.uv, vertices.Count, Vector2.zero); this.AddRangeOfSize(uv2, lightmapUvs, vertices.Count, Vector2.zero); this.AddRangeOfSize(uv3, mesh.uv3, vertices.Count, Vector2.zero); this.AddRangeOfSize(uv4, mesh.uv4, vertices.Count, Vector2.zero); this.AddRangeOfSize(colors, mesh.colors, vertices.Count, Color.black); // ポリゴンの結合 Material[] materials = renderer.sharedMaterials; 36
  40. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 for

    (int subMeshIndex = 0; subMeshIndex < mesh.subMeshCount; ++subMeshIndex) { Material material = materials[subMeshIndex]; if (material == null) continue; int newSubMeshIndex = materialToSubMeshIndex[material]; var baseTriangles = mesh.GetTriangles(subMeshIndex); for (int triangleIndex = 0; triangleIndex < baseTriangles.Length; ++triangleIndex) { int trueTriangleIndex = isReverse ? baseTriangles.Length - 1 - triangleIndex : triangleIndex; var baseTriangle = baseTriangles[trueTriangleIndex]; triangles[newSubMeshIndex].Add(baseTriangle + indexOffset); } } } // マテリアル完成 combinedMaterials = newMaterials.ToArray(); // メッシュへ各種パラメータを設定 combinedMesh = new Mesh(); combinedMesh.SetVertices(vertices); combinedMesh.SetNormals(normals); combinedMesh.SetTangents(tangents); combinedMesh.SetUVs(0, uv); combinedMesh.SetUVs(1, uv2); combinedMesh.SetUVs(2, uv3); combinedMesh.SetUVs(3, uv4); combinedMesh.SetColors(colors); combinedMesh.subMeshCount = combinedMaterials.Length; for (int subMeshIndex = 0; subMeshIndex < combinedMesh.subMeshCount; ++subMeshIndex) { combinedMesh.SetTriangles(triangles[subMeshIndex], subMeshIndex); } // Bounds を再計算 combinedMesh.RecalculateBounds(); // メッシュ最適化 MeshUtility.Optimize(combinedMesh); } // @brief リストに指定サイズまで要素を追加 // @tparam T リストの要素型 // @param list リスト // @param addValue 追加する値 // @param maxSize 最⼤サイズ 37
  41. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 //

    @param defaultValue デフォルト値 private void AddRangeOfSize<T>( List<T> list, T[] addValue, int maxSize, T defaultValue) { // 元々空で、かつ、追加する分がなければ空のまま。 int totalCount = list.Count + addValue.Length; if (totalCount <= 0) return; int diff = maxSize - totalCount; if (diff > 0) { for (int i = 0; i < diff; ++i) { list.Add(defaultValue); } } list.AddRange(addValue); } ここで、実際に結合したメッシュを⾒てみます。 図 3.2: 結合したシーン ライティングが⼤きく変わっています、これは、Lightmap の設定が剥がれてしまってい るためです。そこで、Lightmap を使えるよう、代替機能を作る必要があります マテリアルの Lightmap 対応 Lightmap は 、Lightmapping.lightingDataAsset か ら 参 照 さ れ ま す 。中 に は 古 い Mesh の 参 照 が 残 っ て い る 為 、使 う べ き で は あ り ま せ ん 。そ の た め 、Lightmap- 38
  42. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 ping.lightingDataAsset

    には null を⼊れておきます。ファイトリーグでは、lighting- DataAsset の代理として、 「LightmapMaterialAdapter」2 種類の⽅法で実装しました。 パターン A:LightmapData に直接代⼊する テクスチャ等の参照をランタイムで取り戻せるよう実装してみます。結合時に、 「LightmapSettings.lightmaps」および「Lightmap の対象となる Renderer」から下記のパ ラメータを取得します。 • LightmapData.lightmapColor • LightmapData.lightmapDir • LightmapData.shadowMask • Renderer.lightmapIndex • Renderer.lightmapScaleOffset これらを、SerializeField に保持しておき、Start のタイミングで、元のオブジェクトに 返します。これで、Lightmapping.lightingDataAsset の機能を代理できますが、実⾏時に 以下の警告が出てしまいます。 The renderer xxxx is a part of a static batch. Setting the lightmap scale and offset will not affect the rendering. The scale and offset is already burnt into the lightmapping UVs in the static batch. パターン B:MaterialPropertyBlock でライトマップ⽤パラメータを上書きする こ の ⼿ 法 で は MaterialPropertyBlock を 使 ⽤ し ま す 。結 合 時 に 、 「LightmapSet- tings.lightmaps」および「Lightmap の対象となる Renderer」から下記のパラメータを 取得します。 • LightmapData.lightmapColor • LightmapData.lightmapDir • Renderer.lightmapIndex • Renderer.lightmapScaleOffset Renderer.lightmapIndex は 、LightmapSettings.lightmaps の イ ン デ ッ ク ス で す 。 Start() 内 で MaterialPropertyBlock を ⽤ い 、lightmapIndex の ⽰ す 、Lightmap- Data.lightmapColor、LightmapData.lightmapDir を設定します。 39
  43. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.5 Mesh 結合 Property

    名 パラメータの取得元 unity_Lightmap LightmapData.lightmapColor unity_LightmapInd LightmapData.lightmapDir unity_LightmapST Renderer.lightmapScaleOffset この場合、シェーダーキーワード「LIGHTPROBE_SH」が強制的に設定されてしまい ます。対策として、 「#pragma skip_variants LIGHTPROBE_SH」を記述済みのシェー ダーを設定してください。 結合結果 図 3.3: Lightmap 対応したシーン ⼨分違わず、しかも、同じ⾒た⽬が再現できています。 結合済みアセット参照解除 結合しても、もともと使⽤していた結合済みアセットの接続が残っている場合、結合前 の Mesh がメモリ上に乗ってしまいます。結合に使⽤した Mesh のアセットのみを対象に、 参照を解除しなければなりません。 まずは、結合対象にならなかった Mesh を確認します。結合済み Mesh と同様のアセッ トを参照している場合、これらを Instantiate で複製することで参照が解除されます。 Animator には avatar が参照されています。そして、avatar は FBX 内に参照を持って いる場合があります。結合済みメッシュと同様の FBX が参照されている場合、avatar も Instantiate で複製し、参照解除を⾏います。 次に、SimpleAnimation に FBX 内の AnimationClip が接続されている場合がありま す。AnimationClip の参照も Instantiate で複製し、解除できます。 40
  44. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.6 ポリゴンのソート 参照しているアセットが Prefab

    であれば、Prefab の参照解除も⾏います。Prefab の参 照は、PrefabUtility.DisconnectPrefabInstance で解除できます。 ⼀通りの処理を⾏った後、シーンにアセットバンドル名を指定し、AssetBundleBrowser で⾒てみます。参照の解除が確認できます。 3.6 ポリゴンのソート ポリゴンソートの必要性 ゲーム画⾯のモデルは、描画順序によって処理効率が変動します。たとえば、 「スカイス フィア→地形」の順で描画するとします。すると、⼀度画⾯をスカイスフィアで塗りつぶ したあと、 「⼀度塗った場所にもう⼀度」地形を描くことになります。画⾯に絵を描くコス トを最⼩にする場合、近くにあるものから描くと安く収まります。 (半透明のモデルであれ ば、遠くから描画する必要があります。 ) Unity では GameObject 毎のソートは⾏ってくれますが、ポリゴンのソートは発⽣し ません。そこで、アセットにする時点で事前にポリゴンをソートしてしまいます。これは ファイトリーグのような、 「カメラの座標が⼀定範囲から逸脱しないゲーム」において、有 効です。 カメラ位置の指定 ソートの基準となるカメラ位置を指定します。カメラ位置はポリゴンとの距離を測るの に使⽤します。 ポリゴンをソートする ファイトリーグでは三⾓形の重⼼を計算し、ポリゴンの座標としています。他にも、ポ リゴンのカメラとの近傍、中⼼、傍⼼等、選択の余地があります。ここは、ゲームに合わせ て最適な物を選びましょう。 中⼼点が決まってしまえばあとは、距離を測りソートするだけです。 ポリゴンのソート // @brief メッシュのポリゴン並び順をソート // @param indices インデックス // @param vertices 頂点⼀覧 // @param center ソートの基準点 41
  45. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.6 ポリゴンのソート // @param

    vertexCountInPolygon 1 ポリゴンあたりの頂点数 // @param isAscending 昇順 private void SortPolygon( int[] indices, Vector3[] vertices, Vector3 center, int vertexCountInPolygon, bool isAscending) { // ポリゴン単位にまとめる var polygons = new int[indices.Length / vertexCountInPolygon][]; for (int i = 0; i < polygons.Length; ++i) { int offset = i * vertexCountInPolygon; var polygon = new int[vertexCountInPolygon]; for (int j = 0; j < polygon.Length; ++j) { polygon[j] = indices[offset + j]; } polygons[i] = polygon; } // ソート:優先度(中⼼に近いもの順) var sortedPolygons = new SortedList<float, List<int[]>>(); foreach (var polygon in polygons) { Vector3 sum = Vector3.zero; foreach (var index in polygon) { sum += vertices[index]; } Vector3 g = sum / vertexCountInPolygon; Vector3 centerVec = g - center; var key = centerVec.sqrMagnitude; key = isAscending ? key : -key; List<int[]> tmpList; if (!sortedPolygons.TryGetValue(key, out tmpList)) { tmpList = new List<int[]>(); sortedPolygons.Add(key, tmpList); } tmpList.Add(polygon); } int count = 0; foreach (var pair in sortedPolygons) { foreach (var polygon in pair.Value) { int offset = count * vertexCountInPolygon; for (int i = 0; i < polygon.Length; ++i) 42
  46. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.7 Index のソート {

    indices[offset + i] = polygon[i]; } ++count; } } } 3.7 Index のソート Index をソートする必要性 CPU、GPU ともに連続したデータを処理するほうが効率が良いように設計されていま す。プロセッサにはキャッシュが設定されており、優先的に連続したメモリを乗せるよう になっています。メモリ上において、頂点は Mesh.vertices の順に並んでいます。しかし、 GPU 上では Mesh.triangles の順に頂点シェーダーを実⾏します。つまり、Mesh.triangles 内で隣どうしの値が離れている場合、キャッシュの書き換えが⾛ってしまいます。多くの 場合、FBX などのモデルアセットの triangles は、Unity インポート時に最適化されます。 しかし、先述の Mesh 結合においては、最適化の対象となりません。これは、事前処理だ からこそ必要になるテクニックです。 ⼿法選択 最も簡単なのは、MeshUtility.Optimize を使うことです。キャッシュ効率の優れた Mesh.triangles にソートしてくれます。しかし、Mesh.triangles の中⾝⾃体がソートさ れています。先述の「ポリゴンをソートする」が実⾏されている場合、せっかくソート した順序が崩れてしまいます。ファイトリーグでは Mesh.triangles の順序に合わせて、 Mesh.vertices や Mesh.uv をソートしました。 ⾃⼒でソートしてみる ⾃⼒と⾔ってもあまり⼤したことはしません。たとえば、Mesh.triangles の先頭の数字 が「5」だったとします。下記すべての「0 番⽬」と「5 番⽬」の要素を Swap します。 • Mesh.vertices 43
  47. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.7 Index のソート •

    Mesh.normals • Mesh.tangents • Mesh.uv • Mesh.uv2 • Mesh.uv3 • Mesh.uv4 • Mesh.colors Mesh.triangles を全検索し、すべての「0」を「5」に、すべての「5」を「0」に Replace します。⼊れ替える数字(先程の「0 番⽬」 )をインクリメントします。これを繰り返すだ けです。ポリゴンの順序はそのままに、最適化された Mesh.vertices を扱えます。 Index のソート var triangles = mesh.GetTriangles(subMeshIndex); for (int i = 0; i < triangles.Length; ++i) { int index = triangles[i]; if (index < tail) continue; // 頂点をスワップ vertices.Swap(index, tail); normals.Swap(index, tail); tangents.Swap(index, tail); uv.Swap(index, tail); uv2.Swap(index, tail); uv3.Swap(index, tail); uv4.Swap(index, tail); colors.Swap(index, tail); this.BothReplaceIndexBuffer(mesh, index, tail); triangles = mesh.GetTriangles(subMeshIndex); ++tail; } mesh.SetTriangles(triangles, subMeshIndex); // @brief インデックスバッファの双⽅リプレース // @param mesh メッシュ // @param left 左辺 // @param right 右辺 private void BothReplaceIndexBuffer(Mesh mesh, int left, int right) { for (int subMeshIndex = 0; subMeshIndex < mesh.subMeshCount; ++subMeshIndex) { var triangles = mesh.GetTriangles(subMeshIndex); for (int i = 0; i < triangles.Length; ++i) { 44
  48. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.7 Index のソート if

    (triangles[i] == left) { triangles[i] = right; } else if (triangles[i] == right) { triangles[i] = left; } } mesh.SetTriangles(triangles, subMeshIndex); } } ソート結果 それでは、ソート結果を確認してみましょう。今回、チェック⽤としてインデックス順 に頂点カラーを設定してみました。先頭に近いほど、明るい⾊が設定されます。 図 3.4: ソート結果の確認⽅法 中⼼ほど明るい⾊になっているのがおわかりでしょうか? これで、カメラに近い位置か ら順番に描画されるようになり、描画効率が良くなります。 45
  49. 第 3 章 ファイトリーグにおける Unity のシーンアセット最適化 3.8 他にやれること 3.8 他にやれること

    FBX,Obj 等で出⼒ ⽣成した Mesh インスタンスは Read/Write Enabled であるため、⼆倍のメモリを消費 します。Wavefront Obj 等のフォーマットで出⼒し、ReadOnly にすることで⼤幅に消費 メモリを削減できます。 事前カリング ファイトリーグでは、カメラの座標が⼤きく変動しないため、画⾯に映らないポリゴン が眠っています。これらのポリゴンに対し、カリングすれば事前に Mesh のリダクション を⾏うことができます。 46
  50. 第 4 章 ファイトリーグ環境を OpenAI Gym で 構築した話 4.1 始めに

    ファイトリーグでは、機械学習アプローチを⽤いた盤⾯における配置予測 AI の開発を ⾏っています。すでに教師あり学習では既存のルールベース AI より精度が⾼くなってい ることが確認されており、現在は強化学習アプローチの検証に取り組んでいます。本章で はその取り組みの⼀環として⾏った、ファイトリーグにおける強化学習の実験環境を構築 した話を紹介します。 4.2 背景 機械学習⼿法の⼀つである強化学習の研究は、最近の AI 研究の中でもトレンドとなって います。そのため、⾮常に速いペースで SOTA*1の性能を持ったアルゴリズムが⽣まれて います。またそのようなアルゴリズムを⼿軽に試せるように、Ray RLlib*2や Coach*3と いった OSS の強化学習フレームワークの開発も活発に⾏われるようになってきました。そ してこうした強化学習フレームワークの多くは、強化学習⽤のツールキットを提供してい る OpenAI Gym*4の共通インタフェースに対応しています。このインタフェースに沿って 環境を構築することで、フレームワークが提供しているさまざまなアルゴリズムを使うこ とができ、また分散学習も容易です。私達のプロジェクトでも、強化学習フレームワーク が使えるよう、Gym インタフェースに即した形のファイトリーグ環境を構築することとし ました。 *1 State-of-the-art の略。現時点において最⾼の精度であることを指します。 *2 https://ray.readthedocs.io/en/latest/index.html *3 https://nervanasystems.github.io/coach/ *4 https://gym.openai.com/ 47
  51. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.3 ファイトリーグのバトルシステム 4.3

    ファイトリーグのバトルシステム 今回の強化学習環境となるファイトリーグのバトルシステムについて紹介します。バト ルシステムは図 4.1 のようになっています。中央の枠が 12 マスのバトルフィールドとなっ ており、プレイヤーは空いているマスにファイターを配置できます。そしてその下の枠に は⼿札が表⽰されています。プレイヤーは先⼿番、後⼿番の 2 回⾏動ができ、先⼿番では左 の2枚から1枚、後⼿番では右の2枚から1枚ファイターを配置できます。さらにその下 の枠には FB(ファイティングバースト) という条件を満たした際に発動できるリーダース キルのボタンがあり、このスキルは⼿番を消費せずに発動できます。強化学習下でのファ イトリーグ環境では、環境が「各キャラがどのように配置されているか等の盤⾯情報や⼿ 札等の周辺情報」を状態空間として持っており、エージェントが「フィールドのマス × ⼿ 札 + FB) × 選択スキル*5分の状態」を action の状態空間として持ってます。そして報酬 は、勝利の場合 1、敗北の場合-1 としています。また置けない場所への action が指定され た時も、その試合は終了して-1 が返ります。 図 4.1: ファイトリーグのバトルシステム図 *5 フィールドにいるファイターを選択して発動するスキル 48
  52. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.4 Gym インタフェース

    4.4 Gym インタフェース Gym のインタフェースは、環境がエージェントと円滑にやりとりするためにいくつかの メソッドを定義しています。 • env.reset(self) – このメソッドを呼び出すと環境を初期状態にリセットします。そして、初期状 態の observation を返します。 • env.step(self, action) – 引数に渡された action を⾏い、その結果得られた、次状態の observation、 報 酬値の reward、状態が終了状態であるかの done、 追加情報の info の 4 つを返 します。 • env.render(self, mode=’human’, close=False) – 現在の状態をレンダリングします。 • env.close(self) – 環境の持つ状態を破棄して終了します。 • env.seed(self, seed=None) – ランダムシードの設定を⾏います。 ファイトリーグ環境では、2 つの必須メソッドとレンダリングのための env.render() の合計 3 つを定義することで Gym インタフェースベースの環境を構築しています。し かし、このインタフェースには⼀つの問題があります。それは、Gym のインタフェース が「action によって次状態が決定できる前提で設計されている」という問題です。ファイ トリーグは⼆⼈零和不完全情報ゲーム*6であるため、エージェントは⾃分と相⼿の2体い るのですが、この2体をそのまま同じアルゴリズムで動かそうとすると、env.step() メ ソッドの戻り値である observation が⾃分の action だけでは決まらない問題に直⾯しま す。たとえば⾃分が後⼿番であるとすると、ファイターを配置した後はダメージ計算後相 ⼿のターンに移⾏するので、相⼿のターンが終わらないと次の⾃分の状態が定まりません。 env.step() メソッドの設計はこのような環境の想定がされていないのです。この問題に 対して現状のファイトリーグ環境では、⽚⽅のエージェントをルールベース AI とし学習は カリキュラム学習*7とすることによって対応しています。ルールベース AI はゲームサーバ 側のコードに定義されているので、env.step() メソッドを使⽤したらサーバ側にレスポ ンスを投げて次の状態が返ってくるまでルールベース AI に action を⾏わせ、返ってきた *6 プレイヤーの数が⼆⼈であり、利害が対⽴しており (⼀⽅が勝てば⼀⽅が負ける)、情報が不完全 (相⼿の⼿ 札が⾒えない等) であることを指します。 *7 本来のタスクより難易度の低いタスクを設定し、徐々に本来のタスクに近付けていく学習⼿法 49
  53. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.5 実装前の準備 タイミングで

    observation を返す設計にしています。またカリキュラム学習を⽤いるため、 ルールベース AI の強さを変えていくことで強化学習 AI の強さを上げていくような運⽤を しています。なお、この Gym の設計の問題は issue でも同様の議題が上がっており*8、い ずれこの問題に対応したインタフェースが出てくるかもしれません。 4.5 実装前の準備 ファイトリーグの学習環境を構築する前に、下準備を⾏います。なおこれから先のコー ドは以下のディレクトリ構成を想定しています。 ファイトリーグ環境のディレクトリ構成 gym-fightleague/ README.md setup.py gym_fightleague/ __init__.py envs/ __init__.py fightleague_env.py まず、今回のファイトリーグ環境に必要なパッケージを定義します。 gym-fightleague/に setup.py を作成します。 gym-fightleague/setup.py 1: from setuptools import setup 2: 3: setup(name=’gym_fightleague’, 4: version=’0.0.1’, 5: install_requires=[’gym’, 6: ’numpy’]) 次に gym.env にファイトリーグ環境を登録します。 gym.envs.registration.register を使って⾃作環境を登録すると、 gym.make(’ENV_NAME’) を実⾏することで指定した環境のコンストラクタが実⾏され、環 境の初期状態が渡されます。Gym ではこの gym.make() を書き換えることにより、学習 環境を⾃由に変更できる仕様となっています。gym-fightleague/gim_fightleague に __init__.py を作成します。 *8 https://github.com/openai/gym/issues/934 50
  54. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.6 実装 gym_fightleague/__init__.py

    1: from gym.envs.registration import register 2: 3: register( 4: id=’fightleague-v0’, 5: entry_point=’gym_fightleague.envs:FightleagueEnv’, 6: kwargs={}, 7: ) また、このままではファイトリーグ環境を import する際に名前空間が⻑くなってしま うため、gym-fightleague/gym_fightleague/envs/__init__.py に以下の記述をして おきます。 gym-fightleague/gym_fightleague/envs/__init__.py 1: from gym_fightleague.envs.fightleague_env import FightleagueEnv 4.6 実装 それでは、Gym インタフェースでファイトリーグ環境を構築していきます。環境はすべ て fightleague_env.py の fightleagueEnv クラスで実装されています。まず、クラス と gym.make() で呼び出されるコンストラクタを実装します。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 1: import os, sys, io 2: import numpy as np 3: import json, urllib.request 4: import gym 5: from gym import error, spaces, utils 6: 7: ACTION_NUM = 49 8: TARGET_NUM = 13 9: CPU_TYPE = 6 # random 10: ENDPOINT = ’localhost:4000’ 11: 12: class fightleagueEnv(gym.Env): 13: metadata = {’render.modes’: [’human’]} 14: 15: def __init__(self): 16: self.action_space = gym.spaces.Discrete(TARGET_NUM * ACTION_NUM) 17: self.observation_space = gym.spaces.Dict({ 51
  55. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.6 実装 18:

    ’character_id’: \ 19: spaces.Box( 20: low=0, high=10000, shape=(16,), dtype=np.int32 21: ), 22: ’section_data’: \ 23: spaces.Box( 24: low=0., high=100., shape=(79,), dtype=np.float32 25: ), 26: ’field_condition’: \ 27: spaces.Box( 28: low=0., high=100., shape=(4, 3, 24), dtype=np.float32 29: ), 30: ’enable_actions’: \ 31: spaces.Box( 32: low=0, high=1, 33: shape=(ACTION_NUM * TARGET_NUM,), dtype=np.int32 34: ), 35: }) 36: self.reward_range = [-1., 1.] 37: self.game_id = None 38: self.observation = None 39: self.reward = 0.0 40: self.terminal = False 41: self.enable_actions = [] 42: 43: self.reset() 44: ) 1~5 ⾏⽬で必要なライブラリの import を⾏っています。json や urlib.request を import している理由は、reset や step メソッドで observation を取得する際、ゲームロ ジックの実装があるサーバ側とやりとりするためです。7~10 ⾏⽬で定数を定義しています。 7、 8 ⾏⽬は action の状態空間を定義しています。9 ⾏⽬の CPU_TYPE は、 環境としてみなす 相⼿側のルールベース AI のタイプを指定しています。最初からルールベース AI を強くし てしまうと報酬がもらえず学習が進まなくなってしまうため、 まずランダムな⼿を打つ 6 番 を指定します。エージェントが強くなってきたらこの CPU_TYPE を変えることで徐々に学 習ができるようにしています。12 ⾏⽬~クラスの定義をしています。この際、 gym.Env を継 承する必要があります。16 ⾏⽬の gym.space.Discrete(TARGET_NUM * ACTION_NUM) は action の状態空間の総数を指定しています。17 ⾏⽬~22 ⾏⽬は observation の状態空間 を指定しています。character_id がファイターの情報、section_data が⼿札等の周辺 情報、field_condition が盤⾯情報を指しています。enable_actions は、action に対 して enable_action でフィルタリングしたりする際に利⽤したいため渡しています。30 ⾏⽬で reset メソッドを呼んでいます。これは、gym.make() された際に環境を初期化し て初期状態を渡すためです。次にこの reset メソッドを実装します。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 52
  56. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.6 実装 1:

    ︙ 2: def reset(self): 3: self.observation = None 4: self.reward = 0.0 5: self.terminal = False 6: 7: response = self._api("reset", {’type’: CPU_TYPE}) 8: self.game_id = response[’game_id’] 9: self.enable_actions = response[’enable_actions’] 10: self.observation = {’character_id’: \ 11: response[’character_id’], 12: ’section_data’: \ 13: response[’section_data’], 14: ’field_condition’: \ 15: response[’field_condition’], 16: ’enable_actions’: \ 17: np.sum( 18: np.identity( 19: ACTION_NUM * TARGET_NUM 20: )[self.enable_actions], 21: 0 22: ), 23: } 24: return self.observation 25: ) reset メソッドでは、初期状態での observation を返すための実装がされています。 7 ⾏⽬で_api メソッドを呼んでいます。このメソッドにʠreset"とパラメータとして CPU_TYPE を指定してあげることで、バトルで使⽤するルールベース AI がセットされま す。そして、ゲームロジック側ではエージェント側の状態が返ってくるまで CPU が配置 を⾏っています。そのため、エージェントが先攻であればリーダーのみのフィールドの observation が、後攻であれば指定した CPU_TYPE に沿った⼿を打った後の observation が 返ってきます。下記が_api メソッドの実装となります。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 1: ︙ 2: def _api(self, function, request_json): 3: url = "http://{}/api/debug/agent_game/{}?_r=json".format( 4: ENDPOINT, function 5: ) 6: json_data = json.dumps(request_json).encode("utf-8") 7: request = urllib.request.Request( 8: url, data=json_data, 9: headers={"Content-Type": "application/json"}, 10: method="POST" 53
  57. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.6 実装 11:

    ) 12: response = urllib.request.urlopen(request) 13: response_body = response.read().decode("utf-8") 14: result_objs = json.loads(response_body.split(’\n’)[0]) 15: return result_objs[’data’] この_api メソッドは、同じくゲームロジックとやりとりする必要がある step メソッド でも使⽤します。次はこの step メソッドの実装をします。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 1: ︙ 2: def step(self, action): 3: response = self._api( 4: "step", {’game_id’: self.game_id, ’action’: int(action)} 5: ) 6: self.enable_actions = response[’enable_actions’] 7: self.observation = {’character_id’: 8: response[’character_id’], 9: ’section_data’: 10: response[’section_data’], 11: ’field_condition’: 12: response[’field_condition’], 13: ’enable_actions’: 14: np.sum( 15: np.identity( 16: ACTION_NUM * TARGET_NUM 17: )[self.enable_actions], 18: 0 19: ), 20: } 21: self.reward = response[’reward’] 22: self.terminal = response[’terminal’] 23: return ( 24: self.observation, self.reward, 25: self.terminal, {’foul’: response[’foul’]} 26: ) reset メソッドと同じく、_api メソッドを呼び、返ってきた変数 response の中⾝をそ れぞれの変数に渡しています。step メソッドでは、_api メソッドを呼ぶ際、"step"とパ ラメータとして’game_id’、’action’ を指定しています。ゲームロジック側では渡された action を⾏い、同じように機械学習エージェント側の状態が返るまで CPU が配置を⾏っ ています。最後に、結果が標準出⼒で確認できるように render メソッドと render_cell メソッドを実装します。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 54
  58. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.6 実装 1:

    ︙ 2: def render(self, mode=’human’, close=False): 3: turn_count = int(self.observation[’section_data’][0] * 10) 4: hands = self.observation[’character_id’][0:4] 5: fields = self.observation[’character_id’][4:16] 6: field_cond = self.observation[’field_condition’] 7: 8: outfile = io.StringIO() if mode == ’ansi’ else sys.stdout 9: outfile.write(’\n’ 10: + "terminal: {}, reward: {}, turn_count: {}".format( 11: self.terminal, self.reward, turn_count 12: ) + ’\n’ 13: + "hands: {}".format(hands) + ’\n’ 14: + "field:" + ’\n’ 15: + "+-----+-----+-----+" + ’\n’ 16: + "+{}+{}+{}+".format( 17: self._render_cell( 18: fields[0], field_cond[0][0][19] 19: ), 20: self._render_cell( 21: fields[1], field_cond[0][1][19] 22: ), 23: self._render_cell( 24: fields[2], field_cond[0][2][19] 25: )) + ’\n’ 26: + "+-----+-----+-----+" + ’\n’ 27: + "+{}+{}+{}+".format( 28: self._render_cell( 29: fields[3], field_cond[1][0][19] 30: ), 31: self._render_cell( 32: fields[4], field_cond[1][1][19] 33: ), 34: self._render_cell( 35: fields[5], field_cond[1][2][19] 36: )) + ’\n’ 37: + "+-----+-----+-----+" + ’\n’ 38: + "+{}+{}+{}+".format( 39: self._render_cell( 40: fields[6], field_cond[2][0][19] 41: ), 42: self._render_cell( 43: fields[7], field_cond[2][1][19] 44: ), 45: self._render_cell( 46: fields[8], field_cond[2][2][19] 47: )) + ’\n’ 48: + "+-----+-----+-----+" + ’\n’ 49: + "+{}+{}+{}+".format( 50: self._render_cell( 51: fields[9], field_cond[3][0][19] 55
  59. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.6 実装 52:

    ), 53: self._render_cell( 54: fields[10], field_cond[3][1][19] 55: ), 56: self._render_cell( 57: fields[11], field_cond[3][2][19] 58: )) + ’\n’ 59: + "+-----+-----+-----+" + ’\n’ 60: 61: ) 62: return outfile 63: def _render_cell(self, char_id, is_opponent): 64: if char_id == 0: 65: cell = " " 66: else: 67: if int(is_opponent) == 1: 68: cell = "x{:>4}".format(char_id) 69: else: 70: cell = " {:>4}".format(char_id) 71: return cell 必要な情報を observation から取って表⽰しています。なお、field_cond[0][0][19] には敵か味⽅かの値が⼊っており、_render_cell メソッドで、敵だった場合 x がつき ます。 以上が Gym インタフェースにそったファイトリーグの環境です。試しに、ランダム⾏ 動を⾏う random.py を実⾏してみます。random.py は以下のコードです。 gym-fightleague/random.py 1: import gym 2: import gym_fightleague 3: 4: NUM_EPISODE = 30 5: 6: # initialize game 7: env = gym.make(’fightleague-v0’) 8: # play game 9: for i in range(NUM_EPISODE): 10: print( 11: "====================EPISODE{}====================".format(i+1) 12: ) 13: observation_space = env.reset() 14: done = False 15: env.render() 16: while not done: 17: action = env.action_space.sample() 18: observation, reward, done, option = env.step(action) 19: env.render() 56
  60. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.6 実装 20:

    実⾏結果は以下となります。 ====================EPISODE1==================== terminal: False, reward: 0.0, turn_count: 1 hands: [340, 59, 178, 173] field: +-----+-----+-----+ + +x 89+ + +-----+-----+-----+ + + + + +-----+-----+-----+ + + + + +-----+-----+-----+ + + 179+ + +-----+-----+-----+ terminal: True, reward: -1.0, turn_count: 1 hands: [340, 59, 178, 173] field: +-----+-----+-----+ + +x 89+ + +-----+-----+-----+ + + + + +-----+-----+-----+ + + + + +-----+-----+-----+ + + 179+ + +-----+-----+-----+ ====================EPISODE2==================== terminal: False, reward: 0.0, turn_count: 2 hands: [340, 173, 178, 34] field: +-----+-----+-----+ + +x 89+ + +-----+-----+-----+ + + + + +-----+-----+-----+ + + + + +-----+-----+-----+ + + 179+x 68+ +-----+-----+-----+ 57
  61. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.7 実装後に起きた問題 terminal:

    True, reward: -1.0, turn_count: 2 hands: [340, 173, 178, 34] field: +-----+-----+-----+ + +x 89+ + +-----+-----+-----+ + + + + +-----+-----+-----+ + + + + +-----+-----+-----+ + + 179+x 68+ +-----+-----+-----+ ︙ 4.7 実装後に起きた問題 FightleagueEnv の実装により、gym ベースでエージェントと環境がやりとりできる ようになりました。しかし実⾏結果をみるとわかる通り、このままでは-1 が返ってきてし まってすぐに試合が終わってしまっています。これは、エージェント側がランダムな⼿を 打っていることと、打つことができない⼿を打ったら-1 の報酬で終了という実装なのが原 因です。実際はランダムではなくアルゴリズムに従って打つから問題ないのでは? と感じ られるかもしれませんが、強化学習ではルールは教えずもらった報酬によって学習するの で、ランダムに⾏動して報酬がもらえないと学習が進みません。実際この実装で強化学習 のアルゴリズムを回してみると、やはりなかなかプラスの報酬がもらえず、マイナスの報酬 しかもらえない問題が発⽣します。強化学習では、このような環境のことを報酬がスパー スな環境と呼びます。このような問題を解決するため、リーダーにダメージを与えること に対しての報酬を定義することにしました。step メソッドの self.reward を以下のよう な計算式に変更しています。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 1: def step(self, action): 2: ︙ 3: if self.leader_reward: 4: enemy_leader_hp_rate = \ 5: (response[’enemy_leader_hp’] - self.enemy_leader_hp) \ 6: * self.enemy_leader_hp_rate / 10000.0 7: my_leader_hp_rate= \ 8: (response[’my_leader_hp’] - self.my_leader_hp) \ 9: * self.my_leader_hp_rate / 10000.0 58
  62. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.7 実装後に起きた問題 10:

    11: self.reward = \ 12: response[’reward’] - enemy_leader_hp_rate + my_leader_hp_rate 13: else: 14: self.reward = response[’reward’] 15: この実装は leader_reward が True の際、現在の HP と⼀つ前の状態の HP の差 を取って、引数で指定した rate / 10000 の報酬を計算しています。それを敵味⽅両 ⽅に⾏い、self.reward に合計しています。これにより、リーダーダメージに対して の sub reward の定義を⾏いました。なお、CPU_TYPE 含めこのようなパラメータは fightleague_env.py のコンストラクタに引数で定義するようにしました。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 1: class fightleagueEnv(gym.Env): 2: metadata = {’render.modes’: [’human’]} 3: 4: def __init__( 5: self, leader_reward=False, my_leader_hp_rate=1.0, 6: enemy_leader_hp_rate=1.0, cpu_type=CPU_TYPE 7: ): 8: ︙ また、gym-fightleague/gim_fightleague/__init__.py に以下を追加しています。 gym-fightleague/gym_fightleague/envs/fightleague_env.py 1: ︙ 2: register( 3: id=’fightleague-v1’, 4: entry_point=’gym_fightleague.envs:FightleagueEnv’, 5: kwargs={ 6: "leader_reward": True, 7: "my_leader_hp_rate": 0.5, "enemy_leader_hp_rate": 1.0 8: }, 9: ) このようにしておくことで、gym.make(’fightleague-v1’) を呼び出せば引数で指 定したパラメータでの環境を新たに定義できます。また、スパースな報酬を軽減してい ます。しかしこのままでもまだスパースであるため、実際は enable_actions を使った action_space のフィルタリング等の⼯夫を⾏っています。 59
  63. 第 4 章 ファイトリーグ環境を OpenAI Gym で構築した話 4.8 まとめ 4.8

    まとめ 以上、ファイトリーグにおける強化学習環境実装の紹介でした。今回の執筆では、ゲー ムサーバ側のロジック等、都合上触れていない点や端折ってしまった部分も多々あります。 疑問点がありましたら、ぜひ私のメールアドレス*9にご連絡いただければ幸いです。 *9 [email protected] 60
  64. 第 5 章 Elixir と MUCOM88 で構造的な作曲に 挑戦 5.1 はじめに

    「プログラムで何かを⽣成したい!」では、何を⽣成しよう? と考えていたときに、 「MUCOM88 Windows」と出会いました。ざっくり⾔うと、このアプリは⽂字列を⼊⼒す ると⾳楽ファイルを作ってくれます。 ⼩学⽣のころ PC-9801DX の BASIC で MML を⼊⼒しては、FM ⾳源を鳴らしていま した。30 年の時を経て再びやってみます。 ⾳楽とプログラムの類似性はよく指摘されますが、実際に⾳楽をプログラムで表現する とどんな感じなのか? を試してみます。 想定読者 • プログラムで⾳楽を作りたい⼈ • 8Bit サウンド、16Bit サウンド、FM/PSG ⾳源にときめく⼈ • プロシージャルでジェネレーティブなミニマリスト 読者が得られる知⾒ • MUCOM88 で wav を⽣成する⽅法 • 構造的に⾳楽を作る⽅法 • MML の⼊⾨ • Elixir の⼊⾨ 61
  65. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.2 使⽤する⾔語やアプリ 5.2

    使⽤する⾔語やアプリ Elixir で MML を⽣成し、MUCOM88 Windows で MML から wav を⽣成します。 Elixir Elixir は関数型⾔語です。C#と⽐較すると圧倒的に関数に関わる機能があります。⼿続 き型よりは関数型の⽅が構造からデータを⽣成する⽅法を考えやすいだろうということで 選びました。 MML MML は⾳楽を表現するため⾔語です。今⾵に⾔えばドメイン固有⾔語 (DSL) です。 MML はミニマムに⾳楽を表現します。 昔のパソコンには必ず標準で BASIC という⾔語が⼊っていました。 その BASIC には⾳楽を鳴らすために PLAY 関数があり、その引数にわたすのが MML です。メモリ容量の少ない時代の⾔語ですので、必然的にミニマムです。 MUCOM88 MUCOM88 は有名なゲーム⾳楽の作曲家である古代祐三⽒が使⽤していた「⾃作ツー ル」です。PC-8801 のアセンブラで書かれています。無料公開されています。 MUCOM88 Windows MUCOM88 を Windows で動作させるアプリが MUCOM88 Windows です。PC-8801 の CPU である Z80 と FM/PSG ⾳源のエミュレータが内蔵されています。このアプリの ウィンドウに MML を書くと FM/PSG ⾳源が鳴ります。また、wav で保存できます。 5.3 MML を鳴らす さっそく、MML の練習のため、MUCOM88 Windows で⾳を鳴らしてみましょう。 62
  66. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.4 MML から

    wav を⽣成する MUCOM88 Windows をインストール 1. 公式ページ https://onitama.tv/mucom88/ から "mucom88win190205.zip(最新 β版 ) (1,473,420bytes) (2019/02/05 13:00 update)" をダウンロードします。 2. mucom88win.exe を起動します。 MUCOM88 Windows で MML を鳴らす カエルの歌を MUCOM88 Windows で鳴らすための MML は次のように書きます。こ の MML をエディタ画⾯に⼊⼒し、F5 キーを押すと⾳楽が鳴ります。 リスト 5.1: kaeru ‘A C192 T120 @78 v9 l4 cdefedc2 l4 efgagfe2 l2 cccc l8 ccddeeff l4 edc2‘ 上記 MML を順番に説明します。 • "A"は A チャンネルという意味です。A から K までチャンネルがあります。 • "C192"はクロック分解数です。4 とか 8 とかで割り切れる数にします。 • "T120"はテンポです。1 分間の 4 分⾳符の数です。 • "@78"は⾳⾊番号です。MUCOM88 Windows はデフォルトでたくさんの⾳⾊が登 録されています。 • "l4"はデフォルトの⾳の⻑さを四分⾳符にします。 • "cdefedc"はドレミファミレドです。 • "c2"はドの⼆分⾳符と、個別に⻑さを指定しています。 5.4 MML から wav を⽣成する Elixir を絡めたいのでコマンドラインで wav を⽣成します。ちなみに、MUCOM88 Windows の Menu->Tool->「wav ファイルとして保存」でも wav ファイルを⽣成でき ます。 63
  67. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.4 MML から

    wav を⽣成する 必要なファイルを作業ディレクトリにコピー コマンドラインで wav を⽣成するには次のファイルが必要です。 • mucom88.exe CUI ⽤の本体です。 • mucompcm.bin K チャンネルのリズムの⾳源です。PCM データです。 • voice.dat FM ⾳源の⾳⾊パラメータです。 • 2608_bd.wav G チャンネルのバスドラの⾳です。 • 2608_hh.wav G チャンネルのハイハットの⾳です。 • 2608_rim.wav G チャンネルのリムシンバルの⾳です。 • 2608_sd.wav G チャンネルのスネアドラムの⾳です。 • 2608_tom.wav G チャンネルのタムの⾳です。 • 2608_top.wav G チャンネルのバスドラの⾳です。 リズム⾳源の wav を取得 MUCOM88 Windows の G チャンネルを鳴らすには 2608_*.wav が必要です。しかし、 それらの wav ファイルは MUCOM88 Windows に同梱されていません。別の場所から⼊ ⼿する必要があります。package の readme.txt に詳細がありますのでそれに従って⽤意し てください。readme.txt は GitHub からもアクセスできます。https://git.io/fjvlT MML を作成 下記の kaeru.muc を作成します。muc は MUCOM88 ⽤の MML ファイルの拡張⼦ です。 リスト 5.2: kaeru.muc A C192 T120 @78 v9 l4cdefedc2 l4efgagfe2 l2cccc l8ccddeeff l4edc コンパイルして wav を⽣成する リスト 5.3: mub を⽣成して、wav を⽣成する 64
  68. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.5 Elixir から

    MML を⽣成する MUCOM88 -c kaeru.muc -o kaeru.mub MUCOM88 -x kaeru.mub -w kaeru.wav ⽣成された kaeru.wav は⾳楽再⽣アプリで再⽣できます。 5.5 Elixir から MML を⽣成する Elixir のインストール Chocolatey を使⽤すると簡単に Elixir をインストールできます。Chocolatey は Win- dows ⽤のパッケージ管理ソフトです。Mac でいうところの Homebrew です。 リスト 5.4: Elixir のインストールコマンド choco install elixir Elixir から MML を⽣成する まずは、単純に出⼒します。 リスト 5.5: kaeru-01.exs "A C192 T120 @78 v9 l4cdefedc2 l4efgagfe2 l2cccc l8ccddeeff l4edc" |> IO.puts リスト 5.6: kaeru-01.exs を実⾏して kaeru.muc に保存するコマンド elixir kaeru.exs > kaeru.muc 65
  69. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.6 関数⾔語で⾳楽を表現する makefile

    で作業効率アップ 筆者はエディタとして Emacs を使⽤しています。ctrl-c, c で make -k を実⾏したいの で、wav ファイルが⽣成されるように makefile を書きました。 リスト 5.7: makefile に kaeru.wav を⽣成するコマンドを追加 kaeru: -elixir kaeru.exs > kaeru.muc -mucom88 -c kaeru.muc -o kaeru.mub -mucom88 -x kaeru.mub -w kaeru.wav makefile の凝った機能は使⽤していません。シェルコマンドを実⾏するだけです。 ただ、mucom88.exe がエラー以外に 0 以外を返します。 そのため、コマンドの前に"-"を付けて、戻り値が 0 以外の場合も次のコマンドを実⾏す るようにしました。 5.6 関数⾔語で⾳楽を表現する ⾳楽には縦の構造と横の構造があります。縦の構造は⾳の重なりであり、横の構造は時 間です。まさに楽譜のイメージですが、縦軸で楽器のパートや和⾳が表現し、横軸でリズ ムや曲の展開を表現します。 ⾳楽の横軸 横軸をもう少し分解します。⾳楽をよく聞くと、だいたい 8 拍でまとまっており、その 8 拍が 4 個でまとまりを作っています。8 拍やその集合を表現するちょうどよい⾔葉が⾒つ からなかったので、⽤語を定義します。 • フレーズは 8 拍 • ブロックはフレーズの集合 • 曲はブロックの集合 定義した⽤語を⽤いて、ある曲を分析したところ、ブロック構成はイントロ,A メロ,B メ ロ, サビ,A メロ,B メロ, サビ, 間奏,c メロ, サビ, アウトロ でした。イントロは 2 フレーズ、 そのほかのブロックは 4 フレーズでした。A メロが複数回かありますが、同じメロディー ではあるものの、伴奏の楽器構成が違いました。 66
  70. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.6 関数⾔語で⾳楽を表現する ⾳楽の縦軸

    ⾳楽は⾳が重なっています。複数の楽器パートで構成されます。たとえば、ドラム、ベー ス、ギター、ピアノ、ボーカルという構成です。それぞれのパートは単⾳のときもあれば同 時に複数の⾳が重なっていることもあります。 ドラムならキック、スネア、タム、ハイハット、クラッシュシンバルで構成されていま す。ピアノとギターはドミソなどの和⾳、複数の⾳を同時に鳴らします。 ブロック単位でそれぞれが⼤胆に変化します。たとえば、ギターは最初の A メロはコー ドをそのまま鳴らしていても、2 回⽬の A メロはアルペジオになったりします。また、ド ラムはあるブロックではハイハットだけになるなど、登場する楽器が変化します。 コーディングのための定義 関数として表現するためにあらためて⽤語を定義します。 • ⾳楽はブロックの集合 • ブロックはパートの集合 • パートはフレーズの集合 • フレーズは 8 拍分の MML • フレーズは 2 個のユニット • ユニットは 4 拍分の MML フレーズをコードで表現 ここまでの考察をコードに落とし込みます。抽象的な話もコードに落とすと具体的にな ります。構造を関数で表現し、MML を⽣成します。 リスト 5.8: kosei.exs defmodule Kosei do def gen_phrase(:bass_11) do gen_units([:bass_1_1, :bass_1_1]) end def gen_units(units) do units |> Enum.map( fn unit -> gen_unit(unit) end) end def gen_unit(:bass_1_1) do "K0l16[gc>dg<]4 " end def gen_unit(:bass_1_2) do "K7l16[gc>dg<]4 " end 67
  71. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.6 関数⾔語で⾳楽を表現する end

    Kosei.gen_phrase(:bass_11) |> IO.puts リスト 5.9: elixir kosei.exs の実⾏結果 K0l16[gc>dg<]4 K0l16[gc>dg<]4 曲の構造をコードで表現 ダンス⾳楽にありがちな、ベースを抜いたり、ドラムだけ変えたりなどを繰り返しながら 曲をふくらませることは簡単にできるようにします。イントロ、A メロ、B メロを atom の リスト表現します。曲の構成を、イントロ,a,b,a,b、イントロ,a,a,b,b としてみました。ブ ロック単位で順番を⼊れ替えることで、新鮮さを維持したまま曲を膨らませられます。 リスト 5.10: gen_music def gen_music() do [:header, :intro, :amelo, :bmelo, :amelo2, :bmelo2, :intro, :amelo, :amelo2, :bmelo2, :bmelo2, :outro] |> gen_blocks() |> to_mml() end 曲が完成 パートを増やしたり、構成を修正したりして、なんとか曲を作ることができました。部 分と全体がつながっていて、少しいじるだけで、曲全体が⼀気に変わるのはとても気持ち がよく、気楽にフレーズの変更などを⾏えました。 MUCOM88 Windows のウィンドウで実験したり修正し、コードに書き戻すというのは、 まさに REPL(Read-Eval-Print Loop) です。本来なら⼯程が分かれるであろう、試⾏錯誤 と全体の製作が同時に⾏えます。 68
  72. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.7 ハマったこと 5.7

    ハマったこと やってみていくつかハマったことがあります。 wav が⽣成されない コマンドラインの MUCOM88 で wav を⽣成するのに苦労しました。 リスト 5.11: ⽣成できなかったコマンド mucom88 -x kaeru.muc -w kaeru.wav 上記のコマンドで直接変換できると思いこんでいました。しかし⽣成されません。し ばらく悩んだあげく公開されているソースコードを読みました。ソースコードは GitHub https://github.com/onitama/MUCOM88 にあります。 muc からを mub に変換したあとに、mub->wav にする仕様でした。 リスト 5.12: ⽣成できたコマンド mucom88 -c kaeru.muc -o kaeru.mub mucom88 -x kaeru.mub -w kaeru.wav 上記のコマンドで無事 wav が⽣成できました。 ⽣成された wav が無⾳ やっと、サンプルの曲から wav ファイルが⽣成できた思ったら無⾳です。何も鳴ってい ません。原因は、mucom88.exe の場所にパスを通して実⾏していましたが、⾳⾊データ 読み込めていませんでした。mucom88.exe は voice.dat ファイルをカレントディレクトリ から読み込みます。-v で voice.dat を明⽰的に指定する⽅法があります。とはいえ、作業 ディレクトリにコピーすることにしました。 69
  73. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.8 感想とまとめ リズムが鳴らない

    1 ⽣成された wav ファイルの、K チャンネルが鳴っていませんでした。原因は、前述 と同様ですが、リズムの⾳声データが読み込めていませんでした。作業ディレクトリに mucompcm.bin をコピーすることで解決しました。 リズムが鳴らない 2 ⽣成された wav ファイルの、G チャンネルが鳴っていませんでした。リズムの⾳声デー タが読み込めていませんでした。2608_bd.wav などの wav ファイルも必要でした。これ は MUCOM88 Windows に同梱されていないため、そもそも存在していませんでした。別 のサイトからダウンロードするなりして⽤意するものでした。 5.8 感想とまとめ ワークフロー ⾳楽を構造で扱うことができました。⼊れ替えたり、繰り返したりすることがとても楽 にできます。⾳楽は繰り返しが多く、ただ、繰り返しすだけではなく、少しずつ変化しま す。部分の変更が全体に影響します。このあたりはプログラミングと同様ですので、重複 した部分をまとめることで直交性を獲得して変更に対して柔軟に対応できます。 MML は柔らかい MML は構造がないため、簡単ですのですが、たとえばどこかで 8 分⾳符分ずれたりす ると、どこでずれたのかを探すのに時間がかかります。また、同じフレーズをまとめて修 正するのも⼿間がかかります。 Elixir は奥深い 結局、コードには、重複した表現があります。もっとうまくまとめられそうです。ただ、 まとめすぎると柔軟性がなくなります。データドリブンに寄せたほうがよいのか、アルゴ リズムで⽣成させるほうに寄せたほうがよいのか、迷いながらの実装でした。結局、単純 にデータを返す⼩さい関数をたくさん作成しました。Elixir はコードを⾒直すたびに、重 70
  74. 第 5 章 Elixir と MUCOM88 で構造的な作曲に挑戦 5.9 成果物の場所 複パターンをまとめられてとてもコンパクトになります。

    作曲は奥深い 正直、作曲能⼒はまだまだですが、今回の執筆のために⾳楽の構造についていろいろ分 析し、調査したので少しは成⻑しました。今まで適当に⾳を鳴らすだけだったので、始め から終わりまでを作りきるのはよい経験でした。 5.9 成果物の場所 今回作成した成果物を下記に公開しました。 • makefile https://git.io/fjvCX • techno2.exs https://git.io/fjvcT • techno2.muc https://git.io/fjvcO • techno2.wav https://soundcloud.com/yamayatakeshi/techno2 71
  75. 著者紹介 ⾓ ⿓徳 (第 1 章担当, GitHub: Blasthal, Twitter: @Blasthal)

    ゲームを作っていないと発狂する男。モンストでは主にストライクショットや友情 コンボなどの実装を担当している。エンジニアリングだけでなく、企画、イラスト、 モデル、アニメーション、サウンドなどなど、ゲーム制作に関係すること全般に⾸を 突っ込む。   柳澤 貴宏 (第 2 章担当, GitHub: TakahiroYanagisawa) ミクシィには中途⼊社。以前はコンシューマ機のゲーム開発を主にやっていました。 最近はスマホとコンシューマ機で対戦ができるリアルタイムマルチの仕組みを作っ ています。プライベートではスマホアプリを数本リリースしています。   村上 克弥 (第 3 章担当, GitHub: Katsuya100) 普段はゲーム作りとツール作りと最適化っぽいことをしています。第 3 章の執筆に あたり、⼀番⼤変だったのは、ページ数の削減です。   渡辺莉央 (第 4 章担当, GitHub: simasan0118) ミクシィ 18 新卒で、今は、ファイトリーグの機械学習部分を担当しています。最近 は強化学習を⽤いた配置予測モデルの検証を⾏っています。   ヤマヤタケシ (第 5 章担当, GitHub: toymany, Twitter: @toyman_jp) 近年はスマホ⽤のゲーム開発プロジェクトに参加しています。趣味でアナログカー ドゲームを作ったり、ゲームアプリを作ったりしています。     72
  76. 付録 著者紹介 宮原 ⾹奈⼦(表紙デザイン担当) 普段は web 広告やイベントの KV・ロゴ、紙周りのデザインをやってます。飽きっ ぽいですが最近はトランプのデザインを研究中。  

    杉⽥ 絵美 (プロジェクト推進・制作進⾏等の庶務, GitHub: esugita, Twitter: @semiemi7) 元エンジニアで、ミクシィでは、Perl を触ったり、アプリを触ったり、新規事業の PM をしたりして、今は、DevRel チームで各種イベントを企画・運営したり、技術 的な知⾒のアウトプット活動をサポートしたりしています。   喜多 功次 (制作アシスト, GitHub: kojikita, Twitter: @kojikita) DevRel チームで勉強会やイベントの運営などをしています。元エンジニアで以前は web フロント側のサービス開発やアドテクなどを担当していました。JavaScript が 好きで、最近は PWA に注⽬しています。 73
  77. XFLAG Tech Note vol.02 2019 年 4 ⽉ 14 ⽇ 初版第

    1 刷 発⾏ 著 者 株式会社ミクシィ XFLAG スタジオ 有志 発⾏所 株式会社ミクシィ 印刷所 ⽇光企画   © XFLAG