KLabGames Tech Blog

KLabは、多くのスマートフォン向けゲームを開発・提供しています。 スマートフォン向けゲームの開発と運用は、Webソーシャルゲームと比べて格段に複雑で、またコンシューマゲーム開発とも異なったノウハウが求められる領域もあります。 このブログでは、KLabのゲーム開発・運用の中で培われた様々な技術や挑戦とそのノウハウについて、広く紹介していきます。

カテゴリ: その他

Introduction - はじめに

R&D部に所属しつつ、社内のPlaygroundエンジンを開発しているピコアです。 今回のテーマは最新描画APIのVulkanです。 Vulkanはグラフィックス処理APIトレンドの最先端にあるもので、ゲーム開発者が従来よりもGPUのパワーをきめ細かく制御できます。 そのかわりに開発者がさまざまな責任を負うことにもなります。

Vulkanの使い方や細かなAPIの説明などはネット上のあちこちで見つけられるので、今回はあまり触れません。 この記事の目的は、ゲームエンジンの開発者が実際にVulkanを利用した感想、学習中に得られた経験・苦労、そしてVulkanの良いところについての知見の共有です。

History of 3D Graphic APIs - 描画APIの歴史

まず、グラフィックスAPIの歴史と変遷をおさらいします。 内容は設計思想の流れと時代に応じた変化を理解するための概要に留めます(例えば3DfxのGlideは無視する)。

OpenGL : ご先祖です。1992年1月に公開されました。 当時のGPUの設計は現在とかなり違っていて、決まった固定パイプラインで描画するAPIでした。 制限もありましたが、OpenGLの良いところはそれ以前の仕組みと違って、ハードウェアに依存しないAPIだったことです。 そして関数ポインターを利用して拡張機能を簡単に追加できる仕組みがあります。 そのおかげで、登場から25年近くが経った現在でもOpenGLにはまだ未来があります。 時代と共に技術の進歩へ追従してきた、現代のハードウェアでもまともに動くAPIです。

Windowsの世界ではDirectXが1995年にMicrosoft社から発表されました。
MicrosoftはDOSからWindowsへの移行にあたり、WinGやDirectDrawのAPIを公開したのちに、3D描画のAPIを公開しました。
DirectXの枠組みは音声、コントローラー入力など多くの仕様を含みますが、今回はDirect3Dの話です。 Microsoft社が設計したDirectXの大前提は、COMを使ったOOP(Object Oriented Programming)モデルです。
そして、ハードウェアの進化にあわせて、徐々に新しいバージョンのDirectXをリリースするという方針です。
OS上には以前のDirectXもインストールされており、古いアプリケーションがそのまま動作します。 APIを維持して前のバージョンとの互換性を保つという設計思想ではないことに注意が必要です。
DirectXは1995年~2003年の間に9個ものバージョンがリリースされました(DirectX 1.0 -> 9.0bまで全体的に24 Revisions)。
バージョンごとにAPI設計を大きく変更することで、OpenGLよりも理解しやすく時代にあわせて洗練されたAPI体系を保ってきました。 ドキュメントもOpenGLより詳細に書かれており、またWindows OSがゲーム環境として成長したことで、マーケットをリードするAPIとなりました。

Evolution of APIs over time

1. 固定パイプライン時代

パイプラインとは、頂点の入力からピクセルがバッファに描画されるまでの処理です。
登場当初のDirectXの機能はOpenGLと大差なく、ともに固定パイプラインを採用していました。

固定パイプライン時代は名前の通り、描画機能はすべてハードウェアに実装されたパスで計算され、それ以外の描画オペレーションは選択できませんでした。 このため、光の計算や素材の描画の仕方などはすべて固定されていました。

しかし、それでも進化は激しいものでした。 固定パイプラインといえども、DirectX 7まで変更がありました。そこから、たくさんの変更もありましたが、詳細はリンクを読んで下さい。

対するOpenGLについては、OpenGLのデータパスのイメージ図などを参照してください。

OpenGL対DirectXという枠組みでは政治的な戦いやマーケットでの力関係もあって、性能面やそのあとに出た新ハードウェア機能の対応面などOpenGLが遅れていた部分がありました。 しかし、OpenGLのメリットはWindows環境以外でも動作することであり、生き残る事は出来ました。

2. Shader誕生

DirectX 8.0からシェーダーが追加されました。 ポリゴンの頂点毎に自由な計算(プログラム可能)をおこない、さらに計算結果をピクセル毎に受け取って自由に処理(プログラム可能)できるようになりました。 これらをVertex / Pixel Shaderと呼びます。

OpenGLの世界でも、OpenGL 1.4からシェーダーをExtensionとしてサポートし、2.0から本体サポートに含まれるようになりました(2004年ごろ)。

3. パイプライン内にプログラム可能な部分を追加

その後に、Geometry ShaderやTessellation Shaderも追加されました。 一方DirectX 9まで存在した擬似的な固定パイプラインAPIがDirectX 10で削除されました(OpenGLES 1.x -> OpenGLES 2.xでも同じく、固定パイプラインの機能がAPIから消えた)。 つまりここでプログラマの学習ハードルが高くなりました。

これ以降、画面にピクセルを描画するためには自前でVertex ShaderとPixel Shaderを組むことになります。

4. ステートを先に持っていく、先に情報をフォーマットし、使いまわしの効率をあげる

さらにCPUの負担をおさえるために、ゲームプログラマ側では描画ステートの変更タイミングを変更する取り組みがおこなわれました。 従来は描画実行タイミングでおこなっていた処理を生成/設定タイミングへと移動し、実行時負荷を低減するというものです。

ここで「作成時」と「利用時」を分けて考えた場合、仮に「作成時コスト」が大きくなったとしてもそれはプログラム実行中に一度だけ必要なコストです。 描画に利用するデータを「作成時」に事前計算することで、何度も発生する「利用時コスト」を減らせるならば、積極的にコストを「作成時」へ押し付けて可能な限り「利用時コスト」を0に近づけるのが合理的です。

この考え方とトレンドはのちにVulkanまで続いていきます。

5. より自由度の高いGPGPU(General-purpose computing on GPU)のモデルも追加

そしてComputeモデル(OpenCLまたはNVIDIAのCUDAに近い)も描画APIに追加されました。 つまり描画専用ではなく、もっと自由な形でGPUに計算させる仕組みが追加されました。
これによって例えば物理計算、音声データ処理なども出来るようになりました。 描画の面でも、ピクセルに直接関係しない描画処理にもGPUを利用できます(例:グローバル・イルミネーションの計算)。

ハードウェア性能を限界まで引き出すという点については、歴史的に専用ゲーム機のほうが先行してきました。 PlayStation / Xbox世代のゲーム機においても、粒度のこまかなAPIが開発者へ公開され、性能向上に活用できました。 しかしPCの世界ではマシンごとにハードウェア構成が異なるため、粒度のこまかなAPIを提供することは難しいとされてきました。

6. OpenGLとDirectXに残された大きな弱点: マルチスレッド動作

さて、レンダリング機能が年々強化されていったOpenGLとDirectXですが、ともに処理モデル上の大きな弱点を持っていました。 複数のスレッドを利用して(マルチスレッドで)描画したいという開発者のニーズを満たすAPIの不在です。

まず、OpenGLにはマルチスレッドモデルがまったく存在しないに等しいです。 スレッドごとにContextを持つことはできますが、描画命令を実行できるOpenGLコンテキストはアプリケーション毎に1個しかありません。 マルチスレッドを活用しようとしても、2個目以降は非同期のテクスチャアップロードにしか使えないのです。 さらに、コンテキスト間でのテクスチャ共有には同期の面で注意を払う必要があります。

DirectXでは、DirectX 11からマルチスレッド描画がサポートされました。

DirectX 10までの世代とOpenGLをターゲットとするゲームエンジンのなかには、複数のCPUコアを有効利用するために自前の処理レイヤを構築しているものもありました。 具体的には、グラフィックスAPIを擬似的にマルチスレッド化するラッパーを作り、複数スレッドから描画コマンド列を作成する仕組みです。 記録した描画コマンド列はメインスレッドがまとめてGPUへ転送します。

この仕組みはゲームエンジン側のコードに新たなオーバーヘッド(データ構造をパースしてグラフィックAPI関数の呼び出しに必要な描画ステート、テクスチャ、モデル情報を検索するコスト)を生むため、非効率的と感じるかもしれません。 しかしこのコストを払ってもなお、自前でグラフィックAPI呼び出しに相当するコマンドキューを作成してデータ列を詰め込み、レンダリング用のスレッドがそれを取り出して利用するという仕組みには処理効率上のメリットがありました。

さて、先にDirectX 11でマルチスレッド描画がサポートされたと述べました。 このなかでモデルを変更してコマンド列を貯める仕組みが追加されましたが、貯めたバッファのコミットは依然としてメインスレッドの役目でした。 つまり、DirectX 11の方式はゲームエンジン開発者が自前でコマンド列を貯めて発行していた方式と基本的に変わりません。 唯一の違いはこれらの処理をDirectX自体がおこなうことです。 このため、コマンド列を貯めるのと並行してさまざまな準備処理を先行させられます。 たとえば、コマンド間の差分のみを含むセットへと変換したり、GPUが実行しやすい形に変換するなどの処理です。

7. グラフィックスAPIパラダイムの転換前夜

DirectX 11リリース後、2012年末ごろまでの業界状況は次のようなものでした。

  • ハードウェアの進化ペースは低下しつつも続いていた(例:Conservative Rasterization)。

  • DirectXやOpenGLはプログラマーの開発負担を低減するために、さまざまな処理を裏側でおこなっていた。 場合により、ドライバーがいきなり重い作業をおこなったりと、開発者を悩ませることもあった(この負荷を避けるために、DirectXの癖を把握して、わざとある無駄なオペーレションを実行してタイミングをずらすハックもあったと聞きます)。

この頃のMicrosoft社のスタンスは「DirectXは11で最後」というものでした。 もちろん最後ではないはずですが、しばらくの間はもう抜本的な変更を加えた新しいAPIは出さないという意味でした。

いっぽうで、ゲーム開発者は昔から「たとえ記述が面倒だとしても、できるだけ完全なコントロールが欲しい」と要求してきました。 DirectXやOpenGLの提供する水準ではまだまだ足りないと感じ、専用ゲーム機並の自由なアクセスをしたいというものです。 AAAゲームを出しているゲーム会社の人間は最先端の技術を理解し、できるだけ質の高い物を作ろうとするため、当然の事です。 彼らはMicrosoftのスタンスに対して、別なルートを探していました。 もちろんKhronos Groupの中でもこの議論がおそらくあったと思います(携帯端末でのゲーム市場が伸びる中で、バッテリー消費をおさえつつ高品質なグラフィック処理を実現したいという流れもありました)。

8. Mantleの登場と各社の追従、Vulkanへ

そして、Mantleが登場しました。 MantleとはAMD社(GPUメーカー), EA DICE(ゲーム会社)が組んで開発したAPIです。 Mantleの仕様は、他のGPUメーカーがMantle APIをサポートしたいならば、自由にやって良いという条件のもとで公開されました。 そしてEA DICEのFrostbite 3エンジンでの成果がかなり良く、業界に革命ともいえる大きなショックを与えました。

もちろん、いくら優れたAPIであっても標準仕様ではない独自APIが成功する例は少なく、MantleがメインストリームAPIになる可能性は低いものでした。 さらにMantleはPC向けの独自APIでモバイル環境を考慮していなかったため、たとえばRenderPassの概念が存在しませんでした。

しかし、まずは寝ていた巨人が起きました。 Microsoftが1年のずれでDirectX 12を発表したのです。

そして、Khronos GroupがMantleをベースにVulkanの仕様を仕上げました

さて、この間にApple社がMetalという描画APIを公開しました。 これはVulkanやDirectX 12に近い感じです。 細かくみていませんが、イメージ的にVulkanより扱いやすいと思います。 残念な事にAppleがVulkanをサポートする気配は今のところありません。 Metalの上で実行出来るVulkanのライブラリ(名前はMolten)が存在しますが、どこまで機能が動くのかはわかりません。

重要なのは、VulkanがMantleのコピーではなく、DirectX 12とも違う特徴を持つことです。 VulkanにはMantleにはなかった物も入っています。 VulkanとDirectX 12の概念はとても近いですが、Vulkanははじめからマルチプラットフォームを想定しています。 モバイルも、Windows OSもLinuxもサポートします。 PS4やXbox OneもAMDのチップを使っているので、Vulkan対応が可能だと考えられますが、政治的な意味でVulkan対応の可能性は低そうです。 しかし今後の新しい環境、ゲーム機などがサポートする可能性は十分あると考えられます。

つづいて、Vulkanが従来のAPIとどう違うかを見ていきましょう。

Vulkan addition in more detail - Vulkanが追加する機能と概念

Everything in advance / ほぼすべての状態、設定を先にオブジェクトへビルドする

  • 1/ 全体の描画パイプラインをイメージするオブジェクトを先に作成可能(Input Streamの設定・Shader・Blend・Depth Buffer・Stencil・View Portのdefault値の設定など)
  • 2/ パイプラインにつながるテクスチャーやテクスチャーサンプリング方法、Uniform値を指定
  • 3/ 描画コマンドバッファで描画命令を貯められる。これらは再利用も可能で、どのスレッドからでも作成可能。GPUに送る事も出来る(出来上がったオブジェクトはある状態を表現するため、Immutable型で変更出来ません)。

この3点によって、従来のOpenGLでは描画時にかかっていた処理コストをすべて作成時のコストに移動できます。 事前処理によって内部でGPUの一番わかりやすい形(GPUでの利用時にオーバーヘッドが最も少ない形)のデータに変換することも可能です。 さらに描画命令を貯める事で処理差分のみをGPUに対して発行する最適化も可能で、これも処理負担減に貢献します。

つまり、以前にあった描画時にコミットのオーバーヘッドやステートの変更によってドライバーがやらないといけない重い仕事が消えます。 データはすべて事前に計算されているため、最低限の差分適用で前の処理から次の処理へ切り替えることもでき、GPUにとってもやさしい設計です。

描画コマンドも再利用可能なので、さらにコストダウンにつながります。 一回だけの作成コストで複雑な命令を組み合わせ、その後はずっと描画にコミットするだけです。

ここで重要なのは、描画コマンドのバッファを再利用する際に、画面が全く同じ見た目になる必要はないということです。 例えばカメラの角度を変えたり、キャラクターの場所や動きを変える事は簡単に出来ます。 例えば10万個の草の描画命令を簡単に再利用することが可能です。 コマンドバッファが利用するバッファの中身を差し替えることで、同じコマンドバッファを利用して異なる物を描画する事も可能です。

Reusability in Vulkan

  • Render Passes 利用するバッファ、書き込み用・読み込み用の依存グラフを定義できます。 これによって画面出力までのバッファの流れが整理され、Vulkanドライバがハードの制御を最適化できます。

  • Free Memory Management / 自由なメモリ管理 DirectXやOpenGLでは、メモリのアロケーション時にHandleが戻ってきます(テクスチャー, VBOなど、とにかくオブジェクトポインターやIDが戻ってくる)。

Vulkan APIを利用するアプリケーションでも、Vulkanに対してメモリのアロケーションを要求します。 しかし、このときに対象メモリの用途がテクスチャーなのかバッファなのか、その他のユースケースを明示します。 要求したメモリはOpenGLやDirectXと同じく、Handleとして戻ってきます。

端末のプロフィール情報から、どれぐらいのメモリがあるのか、それらの種類は何か、なども知る事が出来ます。 この情報を利用して、メモリのアロケーション時にどの種類のメモリをどれだけ利用するか設定できます。 そして、割り当てられたメモリを利用する時に必ずすべてのAPIにHandleのオフセットを設定します。

これによって、自然な形ですべてのメモリブロックに対する自前のアロケーションの仕組みを作成できます。 メモリのポインタが直接見えないにも関わらず、Handle + Offsetのお陰でポインタを持っているのと同じ事が可能です。 これはすごく頭の良い仕組みだと思いました。 そして、対象がCPUから見えるメモリであれば、Handleからアドレスにマッピング出来ます(GPUへのメモリ転送によく利用される)。

  • Threading Modelの特徴と制約 基本的にスレッド毎にコマンドバッファで描画コマンドを貯めるのは変わりません。 かわるのはどのスレッドでもコミットが可能という点です。 ただし、同時に2つ以上のスレッドが同じQueueにアクセスしてはなりません。 同時アクセスを防ぐための制御はユーザの責任でおこないます。

すべてのデータの種類に関する、細かなルールは以下のページにあります。

https://www.khronos.org/registry/vulkan/specs/1.0/xhtml/vkspec.html#fundamentals-threadingbehavior

Vulkan Basic - Vulkanの基本

First Setup

初期設定

  • Enumerate Layers (=API Wrappers) and Extensions. ここでVulkan用にインストールされているレイヤー(APIのラッパーの理解で良い、目的によって、いろいろなレイヤーが存在する)を列挙します。 この段階で拡張関数の取得も可能です。

  • Create Instance of Vulkan. Vulkanのインスタンスを作成します。

  • List all physical GPU devices supporting Vulkan. 実際にコンピュター内にVulkan対応の物理デバイス(つまりグラフィックカード)のリストを取得します。
    • Can list / access all the features / properties / memory properties / extensions of device. デバイスごとにサポートされている機能、機能の制限、メモリの種類と制限、拡張の情報を取得できます。
    • Can list all the queues supported by the physical device. そしてデバイスが対応しているQueue一覧(Queueは命令の配列を実行する物)を取得できます。

  • Create a physical instance. 物理デバイスのプロファイルをVulkan APIでインスタンス化します。

  • Create a logical instance from physical device. そして、物理デバイスのインスタンスから、論理デバイスを作成します。

  • Get Queue and all ressources from logical device. 論理デバイスでリソース、Queueなどを取得します(作業はほぼすべて論理デバイスで行う)。

  • Job Done / 設定完了


Swap Chain Setup

Swap Chain / 描画用のスワップチェイン

  • Get Extensions for presentation. 表示用の拡張APIを取得します。

  • Create Surface Surfaceを作成します。

  • Get surface support and possible format. Surfaceが対応しているフォーマットや機能を確認します。

  • Get,select and set presentation mode. 表示の仕方(モデル)を取る、選択し、設定します。

  • Create Swap Chain スワップチェインを作成します。

  • Get images from swap chain. スワップチェイン内からのイメージ一覧を取得します。

    • Create command buffer for post/pre presentation for each images イメージ毎に描画前・後の表示用のCommand Bufferを作成します。
    • Related Image Memory Barrier メモリバリアを作成します。
    • Create View for each image. イメージ毎にViewを作成します。

  • Use 2 semaphores : 2つのセマフォを作成します。
    • One for presentation complete. 表示完了用です。
    • One for rendering complete. 描画完了用です。

そして、

  • Get format properties supported, create Depth/Stencil Buffer,Image and view. 対応できるフォーマットを取得し、Depth/Stencilバッファ、イメージ、ビューを作成します。
    • Allocate necessary memory and bind both. 必要なメモリを割り当ててこれらを紐付けます。

  • For each views of all images from the swap chain, create a framebuffer linking the depth buffer view and the image views.


Rendering / 描画

  • Pre Rendering / 描画前
    • Get Next Image ID. (=Current) / 次の描画バッファのIDを取得します。
    • Post Present Buffer / 描画後コマンドバッファ
      • Begin Command/コマンドの開始
      • Push Image Memory Barrier with Current Image / 現在のイメージオブジェクトにメモリバリアを設定
      • End Command / コマンドの完了
      • Send to Queue / Queueに転送
    • Wait for Queue to complete. (Queue idle wait) / Queueの完了待ち
  • Rendering itself / 描画本体
    • Submit current frame command queues [### エンジンの描画コマンド ###] with following condition / 現在のイメージオブジェクトに合っているコマンドバッファに[### エンジンの描画コマンド ###]を次の条件付きでコミット
      • Wait for "present complete semaphore" / 表示完了セマフォ待ちを指定
      • Set for "render complete semaphore" / 描画完了セマフォ待ちを指定
      • Send to Queue.
  • Post Rendering / 描画後
    • Pre Present Buffer
      • Begin Command / コマンドの開始
      • Push Image Memory Barrier with Current Image / 現在のイメージオブジェクトにメモリバリアを設定
      • End Command / コマンドの完了
      • Send to Queue / Queueに転送
    • Swap Chain Queue Present API with Current Image with following condition :
      • Wait "render complete semaphore" / 描画完了セマフォ待ち
    • Wait for Queue Idle. / Queueの完了待ち

[### エンジンの描画コマンド ###] は自分が描画したい物です。 もちろん、ここで挙げたのは単純なケースで、1つのターゲットバッファしか使わないパターンです。

What Vulkan is for - Vulkanは何のため

Not For :

  • 単純なテスト・単純な物で描画したい場合。基本的にVulkanは通常のアプリケーション本体に書く物ではありません。描画フレームワーク内から呼び出す物です。
  • CPUが描画以外の処理に忙しい場合。Vulkanの性質を活かすためには、描画用の前処理にCPU時間を割く必要があるためです。
  • GPUの細かな事(内部構造)を知らない方
  • OpenGL(ES) / DirectXの仕様を読んで悩んでいる方。
  • Single Thread描画。これはちょっと微妙な意見です。実際Single Threadの場合でもVulkanをうまく使うことで効果は出せます。しかし、性能を最も引き出せるのは複数のスレッドで描画した場合だと思います。

For :

  • CPUバウンド描画。今までOpenGLやDirectXを使って複数のコアで作業していても、描画による処理でCPUが足りなかったパターン。
  • たくさんのdraw callが必要なアプリケーション。
  • 安定した描画時間が必要な場合。ドライバの勝手な裏作業をなくすことで、描画にかかる時間を見積もりやすくなります。

つまり、描画APIの勉強以外、ほとんどOpenGL系でも十分です。 しかも、OpenGL AZDO Extensionを利用すれば、Vulkanとほぼ同じ事を楽にできます。

すなわち、Vulkanは仕事上で専門に描画APIを叩く方に向いている仕組みです。 個人的には、勉強・趣味とゲームエンジン/3D CAD類の開発者以外はあまり調べなくても困らないのではないかと思います。

What I found difficult - 難しいと思ったところ

今回の記事を書くにあたって、Vulkanのすべての機能を使ったわけではありません。 また仕様を細かいところまで完全にマスターして、理解したわけではありません。 あくまでも、使った分の個人的な感想です。

  • ユースケースごとの説明が足りない

このフラグの組み合わせで本当にいいのか、という確証を得られない場合がありました。 このため、あるフラグを設定しない場合はどうなるのかなどの疑問を持ちながら開発を進めました。 特にメモリのタイプ・転送について、またViewの切り替えについて、不明確な部分が多かったです。 例えばRender Targetのreadとwriteの状態を切り替える概念を理解しても、実装・設定に関して、不安なところがありました。

  • 同じくRender passについて、説明が物足りない

APIの説明はいいのですが、ユースケースやパターンに関する説明が不足しています。 APIの説明を読んでも、見えていないところもあります。

  • API仕様書ではなく、概念やユースケースを説明する本が存在しない

新しいAPIであるため一定は仕方ないことですが、これによってDirectXやOpenGLでの経験がないプログラマがVulkanを理解するのが難しくなっていると思います。 また、DirectXやOpenGLのAPIをたくさん叩いた経験があったとしても、内部で起きている事・GPUの設計を理解していなければ理解しにくい部分があります。 暗黙的なノウハウがかなり多いと感じています。

Hints - ヒント

Vulkanを利用した開発をおこなう際にヒントとなりそうなものをいくつか紹介します。

C++ラッパーはオーバーヘッドがなく、しょうもないミスを防ぎやすく、メンテナンスしやすいVulkanコードを組めます。 使わない理由はないと思います。

型安全なEnum指定によって定数指定ミスを防げますし、IDEで自動的に関数やEnumを補完できるため、開発がかなり楽になります。 今回は勉強のために、作業途中でC++版の自前ラッパーを作ったのですが、本記事の執筆時点でNVIDIAがC++ヘッダの自動生成ツールを提供しています。 Vulkanコードを真剣に組むなら、使うべきだと思います。

なお、NVIDIAのC++ヘッダ生成ツールはKhronos Groupからオフィシャルに認められて、2016年7月末からKhronos Groupによってメンテナンスされています

  • Debug Layerを使ったバリデーションを活用しましょう。

大変役に立ちます。 もちろん、もうちょっとわかりやすいエラーを出せばいいのにと感じることもあります。 それでも無言でcrashするより、大変素晴らしいです。 そして、エラーではないが推奨されない指定についてWarningを出してくれるケースもありました。 これによって、コピペによるフラグの設定ミスなどを修正出来ました。

Vulkan Validation Layersの使い方を説明したサンプルコードはこちらです。 http://gpuopen.com/using-the-vulkan-validation-layers/

In the case of Playground

当社で開発している2Dゲームエンジン"Playground"でVulkanを実験的にサポートするために行った対応を紹介します。 エンジン固有の事情も含まれるため、前提を補足しつつ説明します。

  • CPU Batching and dynamic buffer binding.

Playgroundではもともと描画用の頂点データを実行時にCPU側で計算しているため、GPU側のバッファを一切使っていません。 OpenGLのドライバーと同じ働きをし、命令コマンドを貯めながら、転送する頂点のデータも貯める構造です。 そして、描画命令を実行する前に先にメモリの転送を行います。

  • OpenGL like wrapper in engine (not modern stateless)

エンジンの実行時にOpenGLのステート切り替えをしていたため、エンジンのRefactoringはおこなわずに同じ動作を再現しました。 フラグとステートの差分を管理し、使うPipelineを切り替えるようにしました。 (Supported states : Viewport, scissor, depth read/write, depth function.)

  • Dynamic Shaders, target buffers : not supported.

エンジンではシェーダーやターゲットバッファの切り替えをサポートしていますが、今回は最小限の作業で可能な範囲の対応をおこなったため、シェーダーの切り替えやターゲットの切り替え部分は未実装です。

  • Texture Switch. テクスチャーの切り替え

Vulkanでは二つの方法が存在すると認識しているのですが、試したのは片方だけです。 OpenGLやDirectXのようなダイナミックな切り替えはVulkanの場合、PipelineにつなぐDescriptor Set Layoutをテクスチャー毎に作成(OpenGL系でいうと、Uniform値の塊を設定するオブジェクト)することで実現します。 もう一つの方法は、複数のDescriptor Set Layoutを作成せずに、すべてのテクスチャーを配列化して、indexでアクセスする方法です(試していないし、実装方法を調べていません)。

今回対応したものはこんな感じです。

Draw Command In Playground

Vulkan SDK, Sample Code of vulkan over the net :

とにかくVulkanを学んでみたいという方のために、Vulkan SDKとネット上に存在するVulkan関連のサンプルコードを追いかけて学習するためのおすすめフローを簡単に紹介します。

まずはKhronos Groupのホームページから始めましょう。 SDKへのリンク、仕様書、ドライバーなど、すべてそろっています。 https://www.khronos.org/vulkan/

個人的にNVIDIAのカードを持っているため、NVIDIAのドライバーをインストールし、LunarGのSDKもインストールしました。 LunarGのSDKをインストールすると、Debug/Validationレイヤーが追加されるので役に立つと思います。

今回の記事で扱ったのは感想や全体的な話がメインでした。 サンプルコードをもとにして具体的にVulkanを試したい場合は、次の順でみていくと良いでしょう。

Sascha Willemsのサンプルが一番機能が揃っていると同時にやる事が多く、フレームワーク化されています。 このため、生のAPIコールを連続的に綺麗に見ていきたい場合は、少々苦労するかもしれません(LunarGのAPI Dumpレイヤーを使ってよいかもしれませんが)。

将来は?

モダンなAPI(Vulkan, DirectX 12, Metal)はGPUの内部構造にフィットしやすい・計算モデルを近づけているため、今後のハードウェアの進化にあたって、APIも進化すると個人的に思います。 今までハードルだった古いAPIの体系が消えたことで、今後のAPI拡張はハードの性能を十分に引き出しながら進められると思います。 例えば、Raytracingの拡張、Conservative Rasterizationの拡張、などなど、すでに進化は始まっています。 例: http://gpuopen.com/gcn-shader-extensions-for-direct3d-and-vulkan/

Update :

Vulkanの調査とPlaygroundエンジンへの組み込み作業をおこなったのは2016年6月でした。 そしてこの記事を書いたのは7月でしたが、記事公開までの間の7月にSIGGRAPH 2016が開催されました。 このなかでVulkanに関する良い資料が公開されていたので紹介します。

https://www.khronos.org/assets/uploads/developers/library/2016-siggraph/3D-BOF-SIGGRAPH_Jul16.pdf

Conclusion :

Vulkanは低レベルAPIであると言われることが多いですが、個人的にはそうは思いません。 たしかに気をつけるべき事、やる事はかなり増えますが、今までの描画APIの流れで考えると自然なものが多いです。

つまり、Vulkanは完全に新しい概念というわけではなく、これまでの歴史の流れと追加されてきた概念の続きを担うAPIです。 具体的には、可能な限り事前処理できるものを増やす、データの再利用生を高める、描画時の負担を減らす、メモリアクセスをより柔軟にコントロールできるようにする、というトレンドに沿うものです。 これらを実現しやすくなるかわりに、GPUや描画パイプラインの細かいところまですべて理解する必要があります(覚える事は多いですが、昔からやっている描画エンジニアなら特に問題ありません)。

現時点のVulkanが抱える問題は、まだ丁寧なドキュメントがないことです。 ある事を実現したい時に複数の方法を考えつくことが多いのですが、何が「正しい」かという答えが出ない場合もあります(そもそも答えは存在しないかもしれません)。 つまり、ユースケースごとのわかりやすい例がありません。 おそらく何年間もゲーム機やPCでのグラフィック描画で苦労した経験者は悩む事が少ないでしょう。 私は描画APIをバリバリ触ってゲーム機向けの開発をおこなったことがないため、正当な評価をするための能力や経験が乏しい部分もありますが、より暗黙的なノウハウに依存しないマニュアル作りとさまざまな全体図/例の提示がほしいと感じました。

まずは、今後のGraham Sellers(AMD)のVulkan本に期待したいと思います。 https://www.amazon.com/Vulkan-Programming-Guide-Official-Learning/dp/0134464540

しばらく前に、Windows上でTextureMovie機能の実装をしました。TextureMovieというのは動画形式のファイルからフレームを取得し、デコードした結果をOpenGLやDirectX用のテクスチャへ焼きこんでこれらのレンダリングシステムを使って描画するものです。

ここで出たデコードを処理するのはデコーダーで、つまり、何かしらの形式の色情報をRGB(A)に変換するものです。世の中には既成のデコーダーが幾つかありますが、いろいろ事情があって自作という選択肢に至りました。

また、Windowsのことですから、MSが提供している低レベルAPIで何とかなるのかもしれないと考えました。そこで、ほぼRGBとCMYKしか知らない私がいろいろ調べていきました。もし、 色空間の変換やYUV形式に興味がありましたら 、是非お読みください。

まずやりたいことを一言でまとめると、movie.mp4 -> YUVバッファ -> RGBバッファ -> テクスチャという流れです。

最初にWindowsの標準機能であるMicrosoft Media Foundation Transform (MFT)についていろいろ調べました。悪戦苦闘の末、やっと「YUV」でのデータ出力までたどり着きました(MFTに関してはサンプルや資料が極めて少ないため、結構試行錯誤の連続で.mp4からYUVバッファーの出力までできました。それは別の回に改めて紹介することとし、ここでは割愛します)。

いま振り返ると、そこで現れた見知らぬ「YUV」という単語が実に意味深いです。
「YUVって何ぞや、RGBの従兄弟なの?」と思いながら、Wikipediaを開きました。

YUVYCbCrYPbPrとは、輝度信号Yと、2つの色差信号を使って表現される色空間。

とWikipedia先生がこう答えてくれました。

まだよくわからないため、さらにGoogle先生へ答えを求めました。

「人間の目は明るさの変化には敏感だが, 色の変化に は鈍感である」というわけで,色度を抑え、輝度により広い帯域やビット数を割くことにより、少ない損失で効率の良い伝送や圧縮を実現するフォーマット.
デジタル画像の圧縮CODECにおけるフォーマットという観点でまとめる.

つまり、人間の目をうまく欺いて、データの容量を削減するための圧縮フォーマットと考えればいいでしょう。

エンコード(圧縮をかける)したから、デコード(圧縮を解除する)と呼ばれた処理なんだとふっと思いながら、YUVからRGBに変換する方法を探し始めました。

ところが、YUVというのは一つの形式ではなく、幾つかの形式の総称だということに気づきました。MSDNから各形式についてこんな説明があります(以下、図を引用します)。

image04

正直、ぱっと見ただけでは最初に文字も図も今ひとつ分からなかったので、読み飛ばして分かるところまで先に進みました。

まず、そのページの後半にとても重要な計算式が書いてあります(同ページより引用)。

Converting 8-bit YUV to RGB888

From the original RGB-to-YUV formulas, one can derive the following relationships for BT.601.

Y = round( 0.256788 * R + 0.504129 * G + 0.097906 * B) +  16
U = round(-0.148223 * R - 0.290993 * G + 0.439216 * B) + 128
V = round( 0.439216 * R - 0.367788 * G - 0.071427 * B) + 128

Therefore, given:

C = Y - 16
D = U - 128
E = V - 128

the formulas to convert YUV to RGB can be derived as follows:

R = clip( round( 1.164383 * C                   + 1.596027 * E  ) )
G = clip( round( 1.164383 * C - (0.391762 * D) - (0.812968 * E) ) )
B = clip( round( 1.164383 * C +  2.017232 * D                   ) )

where clip() denotes clipping to a range of [0..255]. We believe these formulas can be reasonably approximated by the following:

R = clip(( 298 * C + 409 * E + 128) >> 8)
G = clip(( 298 * C - 100 * D - 208 * E + 128) >> 8)
B = clip(( 298 * C + 516 * D + 128) >> 8)

上記の変換から得たのをもう少し整理すると

R = clip(( 298 * (Y - 16) + 409 * (V - 128) + 128) >> 8)
G = clip(( 298 * (Y - 16) - 100 * (U - 128) - 208 * (V - 128) + 128) >> 8)
B = clip(( 298 * (Y - 16) + 516 * (V - 128) + 128) >> 8)

になります。

これを手に入れたら、あとはYUVの構造さえ分かれば、変換が可能になります。

次に、代表的なフォーマット毎に詳しい説明があります(同ページより引用)。

4:4:4 Formats, 32 Bits per Pixel

AYUV

image06

このフォーマットは輝度毎に色差信号をサンプリングし、アルファ情報もついています。最高クォリティの画質です。

4:2:2 Formats, 16 Bits per Pixel

YUY2

image09

UYVY

image08

このフォーマットはY0とY1が同じUV(U0、V0)を使っています。YUY2とUYVYの差はY、U、Vの並び順だけです。

4:2:0 Formats, 16 Bits per Pixel

※以下のメモリイメージ図はビデオフレームの横幅がY配列の列数と等しく、ビデオフレームの縦幅はY行列の行数と同じです(実際にメモリ上は一次配列扱い。同ページより引用)。

IMC1

image10

IMC3

image05

このフォーマットは四つのYが同じUVを使います。

特徴的な構造として、メモリ上配列でYの値を全部格納してから、Uの配列、Vの配列を置いていることが挙げられます。ただし、UとVの配列はYと同じ幅(行と列の比率、ここはややこしい)なので、ややメモリ利用効率が悪そうです。

また、IMC1とIMC3のメモリ構造はほぼ一緒です。唯一異なるのはUの配列とVの配列どちらを先に並べるかです。

例:

352 * 240のビデオフレームだと

Yの値は352個/行 * 240行(行:列 = 352 : 240 = 22/15)

UとVの値それぞれは176個/行 * 120行(行:列 = 176 : 120 = 22/15)

4:2:0 Formats, 12 Bits per Pixel

これらのフォーマットは全部四つのYが同じUVを使用しています。違いはメモリ上でのデータ格納方法だけです。

IMC2、IMC4

image03
(IMC2)

この形式の特徴はYの値を全部格納してからUとVの値を交替に格納することです。つまり一行の半分はUの値、残り半分はVの値になります。メモリをより効率的に利用できます。IMC2とIMC4の違いはU配列とV配列どちらが先に置かれるかです。また、IMC2はNV12以外で最も使われる形式だと書いてあります(つまり、NV12は最も使われているということで、これは扱いやすさゆえでしょう)。

YV12

image01

正直なところ最初にこの図を見て、IMC1とは何か違うのが分かりませんでした。その後に分かりやすい説明を見つけました(下図は英語版Wikipediaより引用)。

image00

なお、YUV420はYV12の別名です。

簡単にいうと、メモリ上でみた場合にY、U、Vの配列が全部繋がっています。

NV12

image07

同じく、分かりやすい説明が見つかりました(同上)。

image02

NV12のメモリ構造を見ると、最も頻繁に使われる理由が分かります。UVがセットで来るため、ループでとてもまわしやすい構造です。

Yが四つに対し、UVセット一つを使うので、とても自然なプログラムが書けます。

ここまで全部読んだら、一番最初の〇と×の図もちゃんと理解できるようになりました。×はluma = 輝度 = Y、〇はChroma = 色差 = UVということでした。

これで.mp4からテクスチャへの色変換の基礎知識が全部揃いました。計算式とメモリ上のデータ取得部分をプログラムへ組み込み、またMFTで試行錯誤して完成にこぎつけました。

最後に変換部分のコードを抜粋したものを掲載します:

#define CLIP(x) do{if(x < 0){x = 0;} else if(x > 255){x = 255;}} while(0)
#define CONVERT_R(Y, V)    ((298 * (Y - 16) + 409 * (V - 128) + 128) >> 8)
#define CONVERT_G(Y, U, V) ((298 * (Y - 16) - 100 * (U - 128) - 208 * (V - 128) + 128) >> 8)
#define CONVERT_B(Y, U)    ((298 * (Y - 16) + 516 * (U - 128) + 128) >> 8)

void ImplementationMovie::NV12ToRGB(u8* rgbBuffer, u8* yuvBuffer, int width, int height)
{
    u8* uvStart = yuvBuffer + width * height;
    u8 y[2] = { 0, 0 };
    u8 u = 0;
    u8 v = 0;
    int r = 0;
    int g = 0;
    int b = 0;
    for (int rowCnt = 0; rowCnt < height; rowCnt++)
    {
        for (int colCnt = 0; colCnt < width; colCnt += 2)
        {
            u = *(uvStart + colCnt + 0);
            v = *(uvStart + colCnt + 1);

            for (int cnt = 0; cnt < 2; cnt++)
            {
                y[cnt] = yuvBuffer[rowCnt * width + colCnt + cnt];

                r = CONVERT_R(y[cnt], v);
                CLIP(r);
                g = CONVERT_G(y[cnt], u, v);
                CLIP(g);
                b = CONVERT_B(y[cnt], u);
                CLIP(b);
                rgbBuffer[(rowCnt * width + colCnt + cnt) * 3 + 0] = (u8)r;
                rgbBuffer[(rowCnt * width + colCnt + cnt) * 3 + 1] = (u8)g;
                rgbBuffer[(rowCnt * width + colCnt + cnt) * 3 + 2] = (u8)b;
            }
        }

        uvStart += width * (rowCnt % 2);
    }
}

Kyo Shinyuu

WindowsアプリにWebブラウザーの機能を簡単に組み込もうとした時、どういう方法を使いますか?案外とどこから着手したら良いか迷う方が多いのではないでしょうか。

特にマルチプラットフォーム開発の時にWindowsでもIEコンポーネントではなく、iOSのUIWebViewやWKWebView、AndroidのWebViewと似た使い方をできる仕組みがあると便利です。

今回はChromiumEmbedded(CEF3)というオープンソースのフレームワークを紹介します。

CEF3以外にもオープンソースの選択肢としてはWebkitがあります。興味がある方はそちらも検討してください。

CEF3はChromiumの機能をアプリケーション内に組み込むためのフレームワークです。Chromiumの比較的新しいバージョンを利用できるため、いわゆるモダンブラウザの機能をWindowsプログラムへ組み込むのに向いています。

しかし残念ながらあまりドキュメントが充実しておらず、特に少しこだわった使い方をしようとするとハマりどころが多くあります。

また、既にOpenGLを使っているWindowsアプリにWebブラウザー機能を組み込もうとすると、お互いの表示が干渉し合う現象が発生します。その点についても後ほど詳しく記述します。

Windows用WebViewとして実装したい機能

ポジションセット
client上任意のポジションにWebViewを設置すること
リサイズ
WebViewの大きさを変えること
可視性
WebViewの表示/非表示
ScalePageToFit
適切な大きさでページを表示
SetCustomHeader
リクエストのヘッダーをカスタマイズ
JavaScript実行
ページ上のボタンを押すなどのイベントで任意のJavaScriptコードを実行

最初の難関

WindowsアプリにWebViewを組み込む需要はあるはずなのに、CEFの話はあまり聞いたことがありません。

その原因の一つは資料や文献はほとんど英語で、サンプルも少ないことだと考えられます。

どこから着手するのかが分からない人もいれば、途中で挫折して諦める人も少なくないでしょう。

ここでは今回の参考資料のリンクを先に貼っておきます。

CEF wiki
概要をまとめたページで、さまざまな関連リンクがあります。
CEF Forum
先人の知恵はたくさんあるので、はまったことがあったらここでトピックを探すのをおすすめ。
CEF Builds
ここからサンプルやプロジェクトファイルを入手できます。動くものがあれば結構良い足がかりになります。
API Doc
API一覧、メソッドのことならここで一目瞭然です。

CEFの特徴

機能がとても充実していて、パワフルです。しかし全体のイメージを掴むのにはやや学習コストが必要です。いきなり大きな機能をつけるよりも、小さいプロジェクトからスタートしてどんどん機能を追加していくスタイルをお勧めします。

コーディング上の特徴や注意点を簡単に挙げておきます。

C++にはC#やJavaの”interface”に直接該当するものがないので、handlerを多重継承で必要なメソッドを生やすことになります。

RTTIやキャストミスを避けるために、継承したクラスのポインタを取得するgetterを全部上書きしています。

純粋仮想関数の定義が漏れないように継承先のメソッド一覧を一度きちんと把握しておいたほうがいいです。

ポインタは全部STLのshared_ptrのようなクラスにラップされるため、オブジェクトのメモリ解放についてはあまり気にする必要がありません。

実際にCEFを使って必要な機能を揃えるまで

Step 0

サンプルプロジェクトの準備と実行確認

  • CEF BuildsからCEF 3.2556.1368.g535c4fb (もしそれより新しいバージョンがでたら、それを使ってください)をダウンロードし、解凍

  • VS2010用の.slnを開くのがおすすめ

  • ソリューションを開くと、三つのプロジェクトが入っていることを確認

  • 最後のラッパー以外の二つそれぞれスタートプロジェクトとして実行してみる

image02

今回の用途においてはCEF3の全体像を把握するのが重要だと考えました。このため出発地点としてはcefclientのほうがいいと判断し、cefsimpleは不要なので削除しました。

cefclientプロジェクトにはファイルが結構な数ありますが、一通り眺めた結果、ClientHandlerClientAppという二つのクラスがとても重要な役割を担っていることがわかりました。その二つのクラスはそれぞれ幾つかのhandlerクラスを継承し、ブラウザーに対する操作を担います。

image00

Step 1

ClientHandlerClientAppをダイエットさせて、最低限必要な機能だけにしました。

いろいろ試行錯誤した結果、

  • ClientHandlerクラスはCefClientCefLifeSpanHandlerを継承

  • ClientAppクラスはCefAppを継承

という構造にしました。さらにMainWindowの子ウィンドウを作ってそこにブラウザーを生成するようにシンプル化しました。

Step 2

とりあえず現段階できた分をアプリに組み込み(やっと本題に入ります)

ヘッダー等々が認識させるように幾つか工夫が必要

  • 実行に必要なdll等のコピー(サンプルプロジェクト実行する時の.gypから出力したログを読んで把握できます)

  • プロジェクトプロパティの修正(lib内の相対パス参照のエラーを解決するため)

クラス定義やインターフェースを実装してから、ウィンドウの親子関係を整理して実行します。

ありがちな問題

WebViewがちらつく(OpenGLとの干渉が発生)

  • この問題はネットで検索すれば結構出ます。

  • WindowsのマルチウィンドウプログラムとOpenGLの相性の問題はありがちのようです。

  • 解消方法も幾つか書いてありますが、残念ながら自分が試した結果いずれもうまく対応できませんでした。

散々試したあげく、何もしないスーバーウィンドウをメインウィンドウとして作って、アプリウィンドウ(本来のメインウィンドウ、OpenGL使用)とWebViewウィンドウの両方を子ウィンドウにした上で、SetWindowPosでwebviewウィンドウを手前に出すという非常に遠回りな方法で解消しました。

Step 3

機能を増やす

  • ポジションセット

  • リサイズ

  • 可視性

SetWindowPosMoveWindowでウィンドウの位置や大きさを設定できます。ShowWindowで可視性を設定できます。

一点気を付けることがあります。WebViewウィンドウとスーパーウィンドウの親子関係の都合上、スーパーウィンドウの位置が変わった場合にはWebViewウィンドウも一緒に移動させる必要があります。

ありがちな問題

ブラウザーの大きさが変わらない(もしくは微妙に小さくなったりする)

  • 大体の場合、原因はbrowserウィンドウのパラメータ変更忘れです

  • WebViewウィンドウの大きさだけを変えた場合、browserウィンドウが大きくなることはないでしょう。

WebViewウィンドウはOpenGLとの干渉問題を解決するための存在で、browserウィンドウの親ウィンドウです。画面上にブラウザーが一つしかない場合、これらのウィンドウの大きさを完全に同じにすればよいです。また、browserウィンドウのポジションはWebViewウィンドウとの相対位置(デフォルトは同位置)なので、特に設定するの必要はありません。

browserウィンドウはbrowser->GetHost()->GetWindowHandle()で取得できます。

image01

Step 4

更に機能追加

  • ScalePageToFit

  • SetCustomHeader

HTTPリクエスト時にカスタムヘッダを付加する仕組み(SetCustomHeader)は割と簡単です。まずClientHandlerの継承にCefRequestHandlerを追加します。

そしてリクエストの生成時にHeaderMapへとkey-valueペアを登録してLoadRequestを呼ぶだけで終わりです。

ScaleToFitPageの対応は若干手間です。

まず、scale = 1.2 ^ zoomLevelという計算式があります(Forumから入手した情報)。

その逆算でzoomLevel = lg(scale) / lg(1.2)が分かります。

一定の基準となるscaleからzoomLevelを計算できたら、SetZoomLevelを読んでコンテンツの大きさを変更できるはずです

が、大きさが全然変わりません

数時間の試行錯誤の後に、SetZoomLevelをリクエストの処理よりも前に呼んでも何も反映されないことがわかりました。

SetZoomLevelの呼び出し位置を変更し、CefRequestHandlerOnBeforeResourceLoadイベントで呼ぶことでうまく反映されます。

Step 5

JavaScript実行

wiki上にはcef / JavaScriptIntegrationというページがあってJSObjectやJSFunctionなどの記述は全部書いてありますので、これらは省略します。

JavaScriptコードの実行はとても簡単で、基本的にExecuteJavaScriptへ引数を渡せば終わりです。

ありがちな問題

Window Bindingをしたい時に、OnContextCreated()メソッドが全く呼ばれない

  • まず、ClientAppクラスはCefRenderProcessHandlerを継承する必要があります。

  • 初期化の時にCefSettings settings; settings.single_process = true;を設定し、singleProcessにしないと各browserのrenderProcessになります。

後はおまけとして、JSObjectJSFunctionが組み合わせて階層化可能で、javascript:window.JSObject.JSFunction()という構造を作れます。

実際にはJSFunctionJSObjectとして生成し、親のJSObjectの下にぶら下げます。

たとえば、JavaScript側からwindow.engine.func()でC++側の指定メソッドを呼び出したい場合、コードは以下のようになります。

void MyWebView::OnContextCreated(CefRefPtr<CefBrowser> browser,
CefRefPtr<CefFrame> frame, CefRefPtr<CefV8Context> context)
{
  CefRefPtr<CefV8Handler> handler = new MyV8Handler(this);
  CefRefPtr<CefV8Value> obj = context->GetGlobal();

  CefRefPtr<CefV8Value> val = CefV8Value::CreateObject(NULL);
  val->SetValue("func", CefV8Value::CreateFunction("func", handler),V8_PROPERTY_ATTRIBUTE_NONE);
  obj->SetValue("engine", val, V8_PROPERTY_ATTRIBUTE_NONE);
}

CreateObjectで作成したJavaScript側のオブジェクトに対してCreateFunctionで作成した関数を割り当て、JavaScript側のglobalオブジェクト(=Webブラウザ上ではwindowオブジェクト)へぶら下げるのがポイントです。

まとめ

今回はCEF3を使ってiOSやAndroidのWebViewに用意されている機能と似た仕組みをWindows上で実現する方法と、その中で遭遇するいくつかの問題への解決策を紹介しました。

個人的には、CEF3があまり普及しない理由はひょっとすると機能が充実しすぎて、規模が大きすぎるためかもしれないと思いました。

本文でも書いたように、最小化したプロジェクトに対して、追加したい機能に応じてhandlerをどんどん継承し構築していく方法で取り組むのをおすすめします。

Kyo Shinyuu

このエントリーは、KLab Advent Calendar 2015 の12/24の記事です。

2015年新卒の本堂です。

この記事ではロシア語テクストを統計的に分析することについて紹介します。

少しでもコンピュータを使って言語を分析することについて興味をもっていただけたら著者として幸せです。

  • ロシア語を少しでも勉強したことのある方
  • ロシア語を数量的に分析することに興味のある方

を読者として想定しています。

はじめに

僕の専門は言語学、特に言語獲得で、さまざまな言語獲得を実験する過程でロシアにも2年ほど住んでいました。

言語獲得というのは、乳幼児の言語習得や外国語習得などです。

乳幼児と大人の言語獲得スピードの差は今だに人類の謎です。

最近はコンピュータを使った数量的な分析を行う計量言語学も(特に英語で)盛んなので、

乳幼児が言語獲得していく過程と、僕が外国語としてロシア語を習得していく過程を数量的に表現したいと思いました。

例えば、乳幼児が生活に密着した語彙から覚えるのに対して、大人は抽象的な語彙から覚えるとか、

乳幼児は覚えた語彙の使用頻度が高いのに対し、大人は滅多に使わない語彙から覚えるなど。

というようなことを数量化できるかもしれない。

プログラミングは知らなかったので、最初は身近にあったMicrosoftのエクセルで始めました。

excel_stats_sample

このように(どうやったかはもう覚えていませんが)一行に一文、一列に一語ずつ入力して、

エクセルの関数を組み合わせて、なんとか目的の語を入力して出現回数を表示させることができました。

しかし、"я"と"мне"が別々にカウントされています。

(яは"私"の意。"мне"は"я"の与格で"私に"の意。)

格ごとに数えたい場合はこれでもいいですが、僕は語を数えたかったのでどちらも"я"としてまとめてほしいわけです。

"я"などはまだ曲用が少ない方なので、格それぞれの出現回数を合計するといいでしょう。

しかし、ご存知のように"идти"のような動詞は形動詞も含めると数十パターンの変化をするので、

これを手作業で行うのは大変です。

エクセルはロシア語の語形変化など知りませんから、教える必要があります。

どうせ自分で教えるなら自分でプログラムを書こう、と勉強を始めました。

ロシア語の自然言語処理について日本語で書かれた情報が全くないので、最初はとても苦労しました。

今後勉強される方のために情報を残しておこうと思います。

自然言語処理とは

コンピュータでロシア語など人の話す言語を処理する分野を総称して「自然言語処理」といいます。

"мне"を"я"に自動で変換することも自然言語処理の一部です。

プログラムもなんらかの言語によって書きます。

この分野ではPython(パイソン)というプログラミング言語がよく使われます。

自然言語処理やPythonについての説明は、すでに優れた書籍や解説記事がいくつもあるのでそれらを参照してください。

入門 自然言語処理という書籍は特におすすめです。自然言語処理だけでなくPythonの入門書としてもわかりやすく、プログラミングの経験がない僕でも楽しく読み進められました。

英語版であればこちらからPDFで読むことができます。

実際にロシア語テクストを分析してみよう・用語編

それでは実際にPythonを使ってチェーホフの戯曲「かもめ」の使用語彙を分析してみましょう。

その前に、これから使う用語を重要なものだけ整理しておきます。

「トークン」は、テキスト中に現れる語そのものです。句読点の間には適宜スペースをいれてあげます。

例えば、"Отчего вы всегда ходите в черном?"という文は、"Отчего"、"вы"、"всегда"、"ходите"、"в"、"черном"、"?"という7つのトークンからなります。

「レマ」は、そのトークンが見出し語として辞書に載っている語形のことだと思って大丈夫です。トークンは必ず1つのレマをもちます。

例えば、トークン"мне"のレマは"я"、トークン"я"のレマも同じく"я"です。トークン"шедщий"のレマは"идти"です。

「レマ化」とは、トークンをレマに変換することです。つまり、トークン"шедщий"をレマ"идти"に変換することです。

「タグ」は、トークンに対しての付加情報です。品詞、態、性別などの形態情報だけでなく意味情報・統語情報なども含みます。

色々な表記方法がありますが、例えば、"Отчего/疑問詞 вы/代名詞 всегда/副詞 ходите/動詞 в/前置詞 черном/形容詞 ?/句読点"というように記します。

「形態素解析」は、トークンに対して品詞、態、性別など形態論的に解析することです。

実際にロシア語テクストを分析してみよう・事前準備編

プログラミング言語にはPythonを使います。

Pythonについては、Macをお使いの場合最初からインストールされていますし、Windowsであればこちらから簡単にインストールできます。

Windowsの場合はPython3.5と書いてあるものをインストールしましょう。

今回の分析にはPython上にさらに2つのソフトウェアをインストールします。

  • nltk 自然言語処理に必要な機能を網羅的に提供してくれます
  • pymorphy2 ロシア語の形態素解析・レマ化をしてくれます

nltkのインストール方法に関しては上述の『入門 自然言語処理』に丁寧な説明がありますが、

どちらもPythonの"pip"というものを使うと簡単にインストールすることができます。

pipはPythonで作られたソフトウェアのインストールを簡単にしてくれるものです。

Macの場合はターミナルを開いて以下のコマンドを実行します。

sudo pip install nltk pymorphy2

Windowsの場合はコマンドプロンプトから以下のコマンドを実行するだけです。

C:¥Python35¥Scripts¥pip.exe install nltk pymophy2

事前準備はこれで終わりです。

実際にロシア語テクストを分析してみよう・実践編

それではいよいよ「かもめ」の使用語彙を分析してみましょう。

まずPythonを起動します。

Python 3.5.0 (default, Sep 23 2015, 04:42:00)
[GCC 4.2.1 Compatible Apple LLVM 7.0.0 (clang-700.0.72)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

インターネットからテキストを取得する

ダウンロードに必要なプログラムをロードします。

>>> import urllib.request

テキストをダウンロードします。
テキストはこちらのものを使用します。

>>> downloaddata = urllib.request.urlopen("http://www.lib.ru/LITRA/CHEHOW/chajka.txt")
>>> text = downloaddata.read().decode("koi8-r")

ダウンロードしたテキストを整形する

今回の分析では句読点が不要なので削除します。
replaceを使ってスペースに置き換えています。

>>> text = text.replace(".", " ")
>>> text = text.replace(",", " ")
>>> text = text.replace("?", " ")
>>> text = text.replace("!", " ")
>>> text = text.replace("(", " ")
>>> text = text.replace(")", " ")
>>> text = text.replace("\"", " ")
>>> text = text.replace("'", " ")

ダウンロードしたデータを一行ずつに分解します。
splitを使ってテキストを改行で区切ります。
lenでテキストの行数が分かります。大体3900行ぐらいであれば正しく分解できています。

>>> text = text.split("¥n")
>>> len(text)
3913

39行目までは不要な行なのでカットします。
len(text)で39行減っていることが確認できます。

>>> text = text[39:]
>>> len(text)
3874

1行目はtext[0]で確認できます。見たい行番号 - 1 であることに注意してください。
以下のように本文の一部が表示されたらOKです。

>>> print(text[0])
                            ДЕЙСТВИЕ ПЕРВОЕ

1行をトークンに分割してみる

テキストの1行目をline1に保存します。
line1.split()で1行目をトークンに分割できます。
結果がトークンのリストになっていることが確認できます。

>>> line1 = text[0]
>>> l1_tokens = line1.split()
>>> print(l1_tokens)
['ДЕЙСТВИЕ', 'ПЕРВОЕ']

トークンを集計してみる

テキストからトークンを取り出せるようになったので、実際に集計してみましょう。

まずはテキストをトークンに分割してみましょう。
まず、token_listという空のリストを作って、
テキストをfor文を使って1行ずつトークンに分割し、extendtoken_listに加えていきます。
きちんと分割されているようですね。

>>> token_list = []
>>> for line in text:
...      tokens = line.split()
...      token_list.extend(tokens)
>>> token_list
...
'не',
'любит',
'любит',
'-',
'не',
'любит',
'любит',
'-',
'не',
'любит',
'Смеется',
'Видишь',
'моя',
'мать',
'меня',
'не',
'любит',
'Еще',
'бы',
...]

集計のためにnltkのFreqDistというプログラムをロードします。

>>> from nltk import FreqDist

分割したトークンをFreqDistの中に入れて集計してもらいます。

>>> freqdist = FreqDist(token_list)

集計結果を見てみましょう。
most_commonで見ることができます。上位20位まで表示してみましょう。

>>> freqdist.most_common(20)
[('и', 854),
 ('в', 624),
 ('не', 611),
 ('я', 520),
 ('на', 338),
 ('Я', 274),
 ('что', 263),
 ('с', 254),
 ('Маша', 218),
 ('как', 207),
 ('все', 204),
 ('а', 193),
 ('меня', 180),
 ('мне', 171),
 ('у', 169),
 ('Ирина', 162),
 ('вы', 150),
 ('бы', 147),
 ('-', 146),
 ('то', 134)]

集計できました!
確かによく使われそうな語が上位にきていますね。しかし、"я", "Я", "меня", "мне"が別々にカウントされているようですね。ここまではエクセルでも出来たのでした。
次にいよいよレマ化をしていきます。

トークンをレマ化する

まずは例として先ほどのトークンリストにあった"меня"をレマ化してみましょう。

この"меня"(訳:私を)は代名詞"я"(訳:私)が曲用したものですね。これを"я"に変換しましょう。

pymorphy2を使用するのでロードします。このanalyzerが、ロシア語のトークンをレマ化してくれるものです。

>>> import pymorphy2
>>> analyzer = pymorphy2.MorphAnalyzer()

レマ化します。parseでレマ化することができます。可能性の高い順からリストをくれるので[0]で先頭だけ取り出します。normal_formに実際のレマが入っています。

>>> analyzer.parse("меня")[0].normal_form
'я'

うまくできたようですね。ではすべてのトークンをレマ化してもう一度集計してみましょう。
appendを使ってlemmaを一つずつリストに入れています。

>>> lemma_list = []
>>> for token in token_list:
...     lemma = analyzer.parse(token)[0].normal_form
...     lemma_list.append(lemma)
>>> lemma_list
'не',
'любить',
'любить',
'-',
'не',
'любить',
'любить',
'-',
'не',
'любить',
'смеяться',
'видеть',
'мой',
'мать',
'я',
'не',
'любить',
'ещё',
'бы',
...]

先ほどのトークンリストを比べてみると確かにレマしかなくなっていることが確認できます。

token_list lemma_list
не не
любит любить
любит любить
- -
не не
любит любить
любит любить
- -
не не
любит любить
Смеется смеяться
Видишь видеть
моя мой
мать мать
меня я
не не
любит любить
Еще ещё
бы бы

集計してみましょう。

>>> freqdist_lemma = FreqDist(lemma_list)
>>> freqdist_lemma.most_common(20)
[('я', 1229),
 ('и', 952),
 ('не', 729),
 ('в', 725),
 ('вы', 473),
 ('что', 380),
 ('на', 357),
 ('быть', 334),
 ('он', 332),
 ('а', 313),
 ('с', 304),
 ('весь', 304),
 ('это', 256),
 ('она', 253),
 ('как', 250),
 ('маша', 240),
 ('у', 229),
 ('ты', 220),
 ('мой', 217),
 ('ирина', 195)]

先ほどの"метя", "мне"がすべて集約されて"я"が1位になりましたね!

おわりに

以上簡単でしたが1作品の使用語彙をレマ化して集計するところまで紹介しました。
意外と簡単にできるなと思っていただけたらうれしいです。
NLTKにはまだまだ色々な機能があるので、ほとんどプログラムを書かなくても面白い分析ができます。
ロシア語関連のソフトウェアもたくさんあるので色々触ってみて新しい発見に繋げていただけたら幸いです。
最後まで読んでいただきありがとうございました。
興味はもったが分からない!ということがありましたらお気軽にコメントください。

紹介しきれなかった有用なツールが他にもいくつかあるので最後に簡単に紹介します。

その他ロシア語自然言語処理に有用なツールの紹介

形態素解析・レマ化ツール

Mystem

Mystemはロシアで最大の検索エンジンを作っているYandex製の形態素解析・レマ化ツールです。

商用利用は禁止されており、教育・学術目的のみ利用が許可されています。

Pythonから使うこともできます。

digsolab/pymystem3

TreeTagger

TreeTaggerはロシア語に限らず形態素解析に使われているツールで、日本語でも多くの紹介記事が見つけられます。

商用利用は禁止されています。

インストール方法はやや面倒ですが、こちらに従って丁寧に進めるとインストールできます。

ロシア語にも対応させることができて、

後述のRussian National Corpusで訓練されたパラメータファイルをリーズ大学のSerge Sharoffさんが配布してくれています。

russian.par.gz

Pythonから使うこともできます。

TreeTaggerWrapper

コーパスなど

Национальный корпус русского языка(Russian National Corpus)

コーパスとは、言語データを大量に収集・整理したもので、

収集された生のテキストには、形態情報・意味情報などのメタデータが付与されます。

大量のメタデータを利用して言語を統計的に扱うことを可能にします。

Национальный корпус русского языка(以下、НКРЯ)は現在のところロシア語の唯一にして最大のもっとも権威のあるコーパスです。

1.5億語程度の規模だそうです。

大部分が文語で構成されていて、わずかに口語も含まれています。

プログラム+手動で丁寧にタグ付けされています。

ブラウザからの検索インターフェイスも用意されていて、

形態情報、意味情報など詳細な条件で検索することができます。

商用利用は禁止されており、利用は科学・教育分野に限られています。

また、コーパスの生のデータを取得するには別途許可が必要ですので、

こちらをご覧ください。

OpenCorpora

OpenCorporaは、利用者同士が協力して構築しているロシア語コーパスです。

上サイトからコーパスの生のデータをダウンロードすることができます。

商用利用も可能です。

この記事で使用したpymorphy2はこのコーパスを元に形態素解析・レマ化を行っています。

ロシア語版青空文庫

日本語の青空文庫にあたるものは多数あります。

一番上のБиблиотека Максима Мошкова が一番有名だと思います。

このエントリーは KLab Advent Calendar 2015 の12/23 の記事です。

VoQn です。KLab で現行は主に内製のUnityライブラリ開発をやってます。

今回は特にソフトウェアな話というより、ピープルウェアの話をします。

問題「やっていく気持ち」

サービスでもツールでも、当然 「必要がある」 から、あるいは 「価値がある」 から開発に着手するわけですけれど、
その意義を信じきれるかどうかは個人の心象に依ります。

または、当初は意義のあるミッションを担っていると思っていたとしても、開発が難航したりある程度の目処がついたタイミングで、

  • 「本当にすべきことをしているんだろうか」
  • 「他にすべきことがあるような気がする」
  • 「完成させたところで、そこまで価値のあるモノを出してない気がする」

と、余計な雑念に囚われて生産性が下がったり、完了が遠のいたりしてしまう事があります。

これは最初にどれだけ意欲が高かろうと出会う問題で、当人の意思の問題であろうと、結果的には成果そのものに影響しますし、

「君たち、最近モチベーションが低いぞ。もっと熱意を持って!」 と叱咤激励して解決するものではありません。

対策の方針「(意識が低かろうと)やっていけるようにする」

この記事は、過去に自身が経験した 「やっていく」 プラクティスを紹介しつつ、自戒として、「来年も一年頑張るぞい…」と奮起するような内容です。

[見積もり編] 1.「必要になる工数」より「成果を確認する期日」

無論、無理な締め切りを設けても良くないのですが、全ての課題で正確な作業量が見積もれる事は稀で、プロジェクトの進捗を大きく狂わせないように 「ピボットするための期日」 を予め明確に設けた方が結果的にマシな成果に落ち着きます。

最初は「どんなに軽くても1スプリント(2週間=10日)を最短として提示する」のがコツです。10人日という工数でなく、2週間後に成果物をチェックする、という約束がポイントになります

1営業日は1人日ではない

1 「急ぎでなくていいから」と言われて迷ったとき

こう返しましょう。 「リリースできないと困る時はいつまで?」

2 「要求がデカすぎる」としか思えないとき

「欲を挙げたら一ヶ月も二ヶ月もかかるぞ」 としか思えないトピックに対しては 「2週間待ってくれ、プロトタイプをでっちあげてみせるから」 と一旦そこで合意をとって、その約束は守った上で計画の修正を約束してもらうようにします。

[見積もり編] 2. 「優先度」と「難しさ」と「煩わしさ」は別々に

プランニングポーカーなどで、長期計画のタスクの優先度や難易度をチームで重み付けするにあたって、 『だるさ』(億劫度) は必ず事前に予測するようにします。

実際の業務でも 「技術的な難しさ自体は簡単な方だけれど… とにかく、着手するのは億劫だ」 と感じるモノゴトはあります

  • 技術的 『妥協点』 を求められること
    • 既存の運用との折衝や合意形成を必要とすること
    • 必要な開発コストに対して、求められる期日に明らかな無理がある
    • 要件がそもそも 「夢のような技術」 を求めていること
      • 再度 実現可能な要件に分解する交渉が要る
  • 自動化されてない、膨大で退屈な作業を要するもの
    • OSのバージョン毎の挙動確認を要するコト
    • Webブラウザ毎の挙動確認を要するコト
    • ... etc

スプレッドシートなどに課題をまとめる時、こんな風にします

みっしょん 優先度 難易度 億劫度 備考
複数の端末解像度に対応したUIレイアウトの仕組みを作る 4 2 5 実機テスト必須(iOS 3機種、Android 3機種)
多言語対応でUIアセット切り替えする仕組みを作る 3 3 5 検証用素材を日中韓英ぶん用意せな…

重要なのは、この「煩わしさ」は、「そのタスクを完了した際に、どれだけ体力と意欲を使い果たすか」 の指標であることです。

「技術的には難しくないから、経験の浅い人にまとめてやってもらおう」と、そうした「だるいタスク」を集中させてしまうと、あっという間に人員が疲弊して 最悪離職します

恥ずかしながらこういう経験があります。

  • 「だるさ見積り」した => 予測工数の -5%〜+5% の前倒しor遅延 で済んだ
  • 「だるさ見積り」しなかった => +20%〜40% も遅延した。
    • 終わった後の生産性の低さも本当にもう酷かった。
    • ごめんなさい。。。。

やろう!『だるさ』見積り!本当に大事だよ!

[見積もり編] 3. OKR を意識したバックログ

具体的には Github の issue サマリを記載していく事柄で実践します

  • Objectives : この PullRequest で何ができてほしいのか
    • サマリの見出しに
  • KeyResults : この一連のコミットで具体的に何が変わるのか
    • 箇条書きで

github_issue_sample

Github Issue のフォーマット

また、自分がPullRequestで書く際に意識しているフォーマットは以下の通りです

  • [必須] 目的(どういうことにしたいのか)
  • [必須] TODO(何をするか、したか)
  • [推奨] 留意事項(前方互換性の有無、利用上の注意点、仕様変更)
  • [推奨] レビューポイント(どこを重点的にチェックしてほしいか)
  • [あれば] 検討事項(討議して決めてしまいたいこと)

issue_discussion

[やってく編] 1. ポモドーロテクニック

意識の高い時はこういうシートをコレクト社の 情報カード セクション 5×3 に書いてから退社していました

- [ ] 開発タスクA (8)
  - [ ] モジュールa のテスト追加 (ワークフローHoge と Huga の異常系が不足) (2)
  - [ ] モジュールa のHoge Huga Fooの実装改修 (2)
  - [ ] モジュールb のリファクタリング (2)
  - [ ] モジュールc のインターフェースのDocコメント (2)
- [ ] 頼まれてたPR #XX のコードレビュー (2)
- [ ] 定例の報告書く (1)
- [ ] 休む日の勤怠連絡出す (1)
- [ ] 勉強会資料の草稿書く (2)

丸括弧の中の数字は「そのタスクに今日かけるポモドーロ単位(25分)」を見積りで出します

ポイントは 仕事始めでなく、仕事の終わり に書く癖をつけることで、次の日に出社した時に始めるべき事をちゃんと積み残せる分、ダラダラ深夜まで会社に残るのを防げました

(逆に言えば、こういう習慣を辞めた途端にgdgdになって勤怠が大いに狂ったままになった)

[やってく編] 2. こまめに成果をデモとして見せていく

その時に着手しているコトに対して、一番モチベーションを高く保ちつつも、開発そのものを楽しめるのに一番効果があるやり方でした。

  • 全ての機能が未完了でも、一つでも何か出来たら周りに見せる!
  • 実はバグだらけでも見せる!
  • 普段全然カラミがない人にまで見せて回る!
  • そこでクリティカルなフィードバックを得たり、予想してなかった要件を掘り当てられたりする

プロトタイプやデモアプリケーションや、あるいはスクリーンショットでもいいので、 「今しがた、こんなのが出来た!」 と見せびらかすのは大きな意味がありました。

[やってく編] 3. 未来の自分へありったけ報告

躍起になって開発している最中は自身が抱えてる技術的課題も、それに関する知識も新鮮に記憶していますが、喉元すぎれば熱さ忘れるもので。
開発に関して調べた記事のURLや、読んだ本、キーワードは出来るだけ書き残して、次に同種の悩みにブチ当たった同僚にさっと助け舟が出せるようにしておきます

Git のコミットログに悩み事も綴る

明確に振り返ることが出来ていたプロジェクトでは以下のようなコミットログを意識していました

#${issue_id} ${コミットサマリ}
-- ${何を削ったか}
++ ${何を追加したか、変えたか}
* ${レビューなどでツッコミ入れて欲しいこととか、判断に迷ってる事柄}
-> ${↑ に対して、自分なりに「〜とかでいいのでは」と思っている解決方法}

コードレビュワーに「この件を念頭においてレビューしてほしいんだ」と伝えるための備忘録として使います。

あと、こういう風にコミットログを書けるような程度にコミット粒度を細かくするというのも意識していけます。特に Unity プロジェクトはうっかりすると土石流のようなdiffを産んでしまいがちなので。

定期報告は「手短な項目と冗長な説明」

報告の仕方にもよりますが、定例のミーティングの報告が書面で共有されるのなら、報告は細やかにした方が振り返り時に価値のある資料になります

# とあるタスクについての進捗報告
## Done: 完了させたコト
- (ざっとした報告の概要)
  - (グッダグダした言い訳)
## Doing: 未完了&着手中
- (甘い現状認識)
  - (ダラッダラした言い訳)
## ToDo: 積みタスク
- (雑な課題意識)
  − (意識の低さの言い訳)

グダグダした報告を慎んで、寡黙に「結果を優先してさっさと出そう」としたところ、そっちの方が却って進捗が遅れました。

プロジェクトオーナーからしたら、計画のズレに対し「なぜ?」「どうして?」 が材料として多く持てなければ、 「えっと… 急いで?」と圧(あつ)をかけていく しかなくなるわけで。

「沈黙は金、雄弁は銀」とは言いますが、チームワークにおいて黙ったまま進捗遅れてたら世話ないですし、何より技術的問題を共有しなければ協力し合えないので、手持ちの情報は洗いざらい出しきるつもりでいった方がいいです。

[振り返り編] 1 見積りと実績の差分を計算する

ポモドーロ・テクニックで作業量を記録してあると楽ですし、実態も正確に測れます。
これは、タスクが終わった直後にする方がいいです。

次回に同種の開発項目にあたる際の見積りの精度をより高められれば、未来の自分が苦労しなくて済みます。

数週間すぎてからだと、数字も曖昧になりますし、総括も対策も的確なものでなくなってしまいます。

[振り返り編] 2「学びがあった」ことは広く共有する

成果物以外に 「この開発を経て、何を得たか」 を人に語るのはモチベーションを維持するのにも、自身の成長にも効果があります。

リリースノート以外に、社内のメーリングリストに 「こういうものを作ったよ!こういう風に使えるよ」 と宣伝もかけて流したりしましたが、
それらの技術的なノウハウは(しなかったトピックよりも)忘れずにいられています。

振り返り時に報告するのもいいし、勉強会で発表したり、ブログに書くとかでもいいでしょう。
この記事自体も、そういうことをこまめにやらずに、ひどい下半期を過ごしたことへの反省の意味も込めて書いています。

[各自やっていく編] これらのプラクティスを強要しない

これらは実際にやってみて、確実に効果を実感するものばかりでしたが、その意義を共感させる間もなくチームメイトに啓蒙するのは悪手であるとも感じています。

なぜそうした習慣をはじめるに至ったのか? を省みることもなく、闇雲に強制してしまっては、それが果たしたい目的から逸脱し、ただ漫然と日々の作業を面倒な儀礼として潰してしまうだけです。

これらの習慣は、あくまで 「自分は〜において失態やらかして、そこで導入してみたら効いた」 こととして紹介するに留めています。

むしろ、もっとより簡素で手軽な方法を各自で編み出していいわけですし、そうした小さな事柄でもノウハウとして交換しあえる環境と文化が耕されることが大事かなと思います。

まとめ

各自やっていきましょう

p.s. パズルワンダーランド を応援よろしくおねがいします!

本題には関係ないのですが、最近リリースされた パズルワンダーランド をどうか何卒よろしく応援お願いします!!!

↑このページのトップヘ