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

Python による大規模ゲーム開発環境 ~Cyllista Game Engine 開発事例~

Cygames
September 02, 2020

Python による大規模ゲーム開発環境 ~Cyllista Game Engine 開発事例~

2020/09/02 CEDEC2020

Cygames

September 02, 2020
Tweet

More Decks by Cygames

Other Decks in Technology

Transcript

  1. 自己紹介 • 株式会社 Cygames Cyllista Game Engine シニアゲームエンジニア 沖 幸太朗

    • 大手ゲーム開発会社でコンシューマゲーム開発に従事し, リードプログラマおよびクリエイティブディレクターとしてチームを牽引。 2016年より株式会社 Cygames に所属し, 内製ゲームエンジン「Cyllista Game Engine」の開発サブリーダーとして, アセットシステム・サウンドシステムを中心に開発を進めている。 2/91
  2. Python 8/91 • 汎用の高水準プログラミング言語。 関数型,オブジェクト指向,動的型付けなどの 特徴を持つ。 – https://www.python.org/ • 本体部分は必要最小限の機能のみ。

    パッケージという形で様々な機能が提供される。 • 世界で広く使われているプログラミング言語の1つ。 – IEEE Spectrum で3年連続1位 (2017-2019)
  3. Python の IDE (統合開発環境) 11/91 • PyCharm が非常に便利 – https://www.jetbrains.com/ja-jp/pycharm/

    – コード補完・検査やリアルタイムでのエラー指摘など,非常に優秀 – コード検索も非常に高速 – 有償版では… • 細かな負荷計測 • Perforce 連携 • プロセスアタッチによるデバッグ etc... • このようなツールを使っている人も – Visual Studio Code – Vim
  4. Python の弱点1 -速度の遅さ- 13/91 • Python では単純な処理でも負荷がかかることがある。 • 特に大量の計算が発生する処理が遅い。 –

    動的型付けのため,単純な計算でも型チェックが入ってしまう。 • 大量の計算を行うのであれば NumPy パッケージが有効。 • 複雑な処理を行う場合は pyd が有効。
  5. NumPy 14/91 • 数値計算を高速に行うためのパッケージ。 – ディープラーニング系では必須なパッケージ。 • 多次元配列 (ndarray) –

    四則演算 – 行列積 – 統計量 (平均・分散・標準偏差・最大値・最小値など) etc... pip install numpy
  6. NumPy (行列積演算の時間比較) 15/91 • 例えば N 次元行列同士の行列積を出してみる。 – a と

    b の行列積を c に代入する。 import random a = [[random.random()] * N for i in range(N)] b = [[random.random()] * N for i in range(N)] c = [[0] * N for i in range(N)]
  7. NumPy (行列積演算の時間比較) 16/91 • NumPy を使わない場合 N=100 : 0.2[s] N=200

    : 1.8[s] • NumPy を使う場合 N=100 : 0.003[s] N=200 : 0.007[s] for i in range(N): for j in range(N): for k in range(N): c[i][j] = a[i][k] * b[k][j] import numpy as np c = np.dot(a, b)
  8. pyd 対応 17/91 • pyd とは Python モジュールとして 利用できる DLL

    のこと。 – C 言語でコーディングできるので高速。 – Python.h をインクルード。 • https://docs.python.org/ja/3/extending/ • 右のコードで作った sample.pyd は Python コードからインポート可能。 – 加算処理を行う add() を提供。 #include <Python.h> int add(int x, int y) { return x + y; } PyObject* pythonAdd(PyObject* self, PyObject* args) { int x, y, g; if (!PyArg_ParseTuple(args, "ii", &x, &y)) { return nullptr; } else { g = add(x, y); return Py_BuildValue("i", g); } } static PyMethodDef pythonMethods[] = { {"add", pythonAdd, METH_VARARGS}, {NULL}, }; static PyModuleDef pythonModule = { PyModuleDef_HEAD_INIT, "sample", "Sample Module", 0, pythonMethods, }; PyMODINIT_FUNC PyInit_sample() { return PyModule_Create(&pythonModule); } import sample sum = sample.add(1, 2) # sum = 3
  9. pyd 対応 (モジュール定義) 18/91 • モジュール名を name としたときに, PyInit_name という名前の初期化関数を

    用意する必要がある。 • PyModuleDef 構造体でモジュールの 定義を設定する。 #include <Python.h> int add(int x, int y) { return x + y; } PyObject* pythonAdd(PyObject* self, PyObject* args) { int x, y, g; if (!PyArg_ParseTuple(args, "ii", &x, &y)) { return nullptr; } else { g = add(x, y); return Py_BuildValue("i", g); } } static PyMethodDef pythonMethods[] = { {"add", pythonAdd, METH_VARARGS}, {NULL}, }; static PyModuleDef pythonModule = { PyModuleDef_HEAD_INIT, "sample", "Sample Module", 0, pythonMethods, }; PyMODINIT_FUNC PyInit_sample() { return PyModule_Create(&pythonModule); }
  10. pyd 対応 (関数定義) 19/91 • Python コードからアクセスできる関数に ついて,入力として2つの PyObject*, 出力として

    PyObject* を返す関数を定義。 • 右の例では,args から引数をパースし, その値を実処理 (add()) に渡し, 結果を PyObject* に変換して返している。 #include <Python.h> int add(int x, int y) { return x + y; } PyObject* pythonAdd(PyObject* self, PyObject* args) { int x, y, g; if (!PyArg_ParseTuple(args, "ii", &x, &y)) { return nullptr; } else { g = add(x, y); return Py_BuildValue("i", g); } } static PyMethodDef pythonMethods[] = { {"add", pythonAdd, METH_VARARGS}, {NULL}, }; static PyModuleDef pythonModule = { PyModuleDef_HEAD_INIT, "sample", "Sample Module", 0, pythonMethods, }; PyMODINIT_FUNC PyInit_sample() { return PyModule_Create(&pythonModule); }
  11. pyd 対応 20/91 • …とにかく難易度が高い! – 2つの変数を加算するだけの単純な処理で これだけのコードを書かなければならない。 – PyObject

    の扱いが難しい。 • 参照カウントを自分で操作する必要があり, 誤るとクラッシュの原因となる。 pybind11 を使う!
  12. pybind11 21/91 • 直感的に pyd の C コードが書ける。 – C

    の関数を直接指定すれば,勝手に Python コード用の定義を作ってくれる。 – PyObject の参照カウントを自動的に制御してくれる。 – C と Python の型を自動変換してくれる。 • str ⇔ std::string, std::string_view • list ⇔ std::vector • set ⇔ std::unordered_set • dict ⇔ std::unordered_map • C++11 以上が必要 • https://pybind11.readthedocs.io/en/stable/
  13. pybind11 22/91 #include <Python.h> int add(int x, int y) {

    return x + y; } PyObject* pythonAdd(PyObject* self, PyObject* args) { int x, y, g; if (!PyArg_ParseTuple(args, "ii", &x, &y)) { return nullptr; } else { g = add(x, y); return Py_BuildValue("i", g); } } static PyMethodDef pythonMethods[] = { {"add", pythonAdd, METH_VARARGS}, {NULL}, }; static PyModuleDef pythonModule = { PyModuleDef_HEAD_INIT, "sample", "Sample Module", 0, pythonMethods, }; PyMODINIT_FUNC PyInit_sample() { return PyModule_Create(&pythonModule); } #include <pybind11/pybind11.h> int add(int x, int y) { return x + y; } PYBIND11_MODULE(sample, m) { m.doc() = "Sample Module"; m.def("add", &add); }
  14. Python の弱点2 -並列処理に弱い- 23/91 • Python には GIL (Global Interpreter

    Lock) が存在する – 排他ロック。ロックを持つスレッドのみ実行可能。 ロックを持っていないスレッドはロックが開放されるまで待たされる。 – async などを利用することで,ファイルI/O 等との並列は可能ではあるが, 原則実行されているのは1スレッドのみ。 • multiprocessing モジュールでプロセス分離 – プロセス間は Pipe 通信もしくは共有メモリが利用できる。 (共有メモリは Python 3.8 以降のみ対応)
  15. 高速化事例 -ジョブシステム- 24/91 • 同時に複数の Python のコードを実行したい。 – (例) Python

    で書かれたコンバータで,大量のアセットを並列にコンバートしたい。 • Python 上で動くジョブシステムを作ってみた。 – 1つ以上のジョブをジョブシステムに投入すると,ジョブエグゼキュータが 並列に処理していく。
  16. ジョブシステム サンプルコード 37/91 Job クラス ・ジョブの基底クラス。 ・do_job 関数をオーバーライド してジョブの実行内容を書く。 ・ジョブ実行時に渡す情報は

    JobContext クラスを利用する。 class JobStatus(object): UNEXECUTED = 0 # SUCCEEDED = 1 # ( ) FAILED = 2 # ( ) class JobContext(object): def __init__(self, *args) -> None: # ... class Job(object): def __init__(self) -> None: self._status: int = JobStatus.UNEXECUTED @property def status(self) -> int: return self._status def do_job(self, context: JobContext) -> bool: # raise NotImplementedError
  17. ジョブシステム サンプルコード 38/91 Executor Process ・multiprocessing.Queue を 利用してサーバーとの送受信を 行っている。 ・Job

    インスタンスが受信されたら それを実行し,結果を送信する。 class ExecutorProcess(object): def __init__(self, s2c_queue: multiprocessing.Queue, c2s_queue: multiprocessing.Queue) -> None: self._s2c_queue: multiprocessing.Queue = s2c_queue self._c2s_queue: multiprocessing.Queue = c2s_queue def run(self) -> None: while True: try: job = self._s2c_queue.get() self._exec_job(job) except (EOFError, BrokenPipeError): break except Exception as e: print(str(e)) def _exec_job(self, job: Job) -> None: context = JobContext(...) try: result = job.do_job(context) except Exception: print(str(e)) result = False job.status = JobStatus.SUCCEEDED if result else JobStatus.FAILED self._c2s_queue.put(job) def _process_main(s2c_queue: multiprocessing.Queue, c2s_queue: multiprocessing.Queue) -> None: executor = ExecutorProcess(s2c_queue, c2s_queue) executor.run()
  18. ジョブシステム サンプルコード 39/91 Executor Thread ・multiprocessing.Process で Executor Process を立ち上げる。

    その際に送受信用の Queue を 渡している。 ・Job がキューされたらコピーした Job を送信する。 ・スレッドのループで受信を待ち, 結果を受け取る。 class ExecutorThread(threading.Thread): def __init__(self) -> None: self._s2c_queue: multiprocessing.Queue = multiprocessing.Queue() self._c2s_queue: multiprocessing.Queue = multiprocessing.Queue() self._process: Optional[multiprocessing.Process] = None self._ready: bool = False self._result_job: Optional[Job] = None def run(self) -> None: self._process = multiprocessing.Process(target=_process_main, args=(self._s2c_queue, self._c2s_queue,), daemon=True) self._process.start() self._ready = True while True: try: job = self._c2s_queue.get() self._done_job(job) except Exception as e: print(str(e)) def queue_job(self, job: Job) -> None: self._result_job = None if not self._ready: return self._ready = False self._s2c_queue.put(copy.deepcopy(job)) def release_job(self) -> None: if self._ready: return self._ready = True self._result_job = None def _done_job(self, job: Job) -> None: self._result_job = job
  19. ジョブ内からのジョブ実行 40/91 • ジョブの中から複数のジョブを実行したいときがある。 – (例) アセットのコンバート中に,他の複数のアセットのデータ取得のためにコンバート が必要になったとき。 – 全ての

    Executor Process 内で上記の状況になると,デッドロック状態になってしまう。 • Executor Process の数を自動的に増減できるようにする。 – ジョブ内からジョブを投入しようとしたとき,一定時間以上 Executor Process の空き が見つからなければ,Executor Process を自動的に増加させてそれを利用する。 – 増加させた Executor Process は不要になったタイミングで破棄する。
  20. cy モジュール 48/91 • Cyllista Game Engine の Python ソースコードは,

    ツール起動用のコード以外は全て cy モジュールとして登録。 • cy モジュール例 – cy.asset : アセットシステム – cy.ed : GUI コード – cy.log : ログ – cy.perforce : Perforce 制御 – cy.util : ユーティリティ from cy import log log.info(’Information’)
  21. サーバー 53/91 • 独立したプロセス。 – Python で作成。 • 編集状態を所持。 –

    Undo/Redo 等の情報も。 • クライアントから送信された変更内容 を他の全てのクライアントに リアルタイムに通知。 サーバープロセスは強固に。 安定性を最優先とする!
  22. クライアント 54/91 • ランタイムやエディタに含まれる。 – ランタイム : C++ – エディタ

    : Python • ランタイム / エディタ共にクラッシュ することが多々あるが,サーバーに 再接続したときにサーバーが情報を 保持しているので,それを復元する。
  23. サーバー・クライアント通信 55/91 • サーバー・クライアント間は TCP で通信する。 – コンソール機とも通信する必要があるため,Pipe 通信は使えない。 –

    パケットロスさせないように,UDP ではなく TCP。 • Python 上で TCP 通信を組む場合は,socket / select モジュールで 実装すると良い。
  24. サーバー・クライアント通信 56/91 サーバースレッド クライアントスレッド import socket import select _LISTEN_BACKLOG =

    10 _SELECT_TIMEOUT = 0.1 server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.bind(('', self._port)) server_socket.listen(_LISTEN_BACKLOG) sockets = {server_socket} while self._running: read_sockets = select.select(sockets, [], [], _SELECT_TIMEOUT)[0] for s in read_sockets: if s is server_socket: connection, address = s.accept() sockets.add(connection) # ... else: # ... for s in sockets: s.shutdown(socket.SHUT_RDWR) s.close() import socket _SOCKET_TIMEOUT = 0.1 client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.settimeout(_SOCKET_TIMEOUT) client_socket.connect((hostname, portnumber)) while self._running: try: # ... except EnvironmentError: continue except Exception: break client_socket.shutdown(socket.SHUT_RDWR) client_socket.close()
  25. 外部ツールにおける Python 58/91 • Python で操作することができる外部ツール – Shotgun – Wwise

    • Python が中に組み込まれている外部ツール – Maya – Houdini
  26. Python が組み込まれている外部ツール 60/91 • Maya – Version.2.7 • Houdini –

    Version.2.7 • 最新の Python – Version.3.8 • Cyllista Game Engine – Version.3.7 Python 2系 Python 3系 (EOL)
  27. Python 2系 と 3系 61/91 • 2系 と 3系 は言語仕様がいろいろ異なる。

    – 2系 のソースコードは 3系 の Python で実行できないことがある。 • print 構文の違い。 • 文字列の扱いの違い。 etc... – 2to3 を利用することによって,2系 のソースコードを 3系 仕様に変換可能。 • https://docs.python.org/ja/3/library/2to3.html Cyllista Game Engine の cy モジュールを Maya 上で直接利用したいけれど,出来ない!
  28. Qt for Python (PySide2) 64/91 • Qt とは – クロスプラットフォームアプリケーションフレームワーク

    – https://www.qt.io/jp • Qt for Python とは – Qt の Python バインディング (Qt 公式) – https://doc.qt.io/qtforpython – Maya や Houdini にも導入されている pip install PySide2
  29. 最小限コード 65/91 from PySide2 import QtWidgets if __name__ == '__main__':

    application = QtWidgets.QApplication(sys.argv) label = QtWidgets.QLabel('Hello World') label.show() application.exec_()
  30. Qt for Python の良い点 66/91 • 基本的な GUI コンポーネントや各種システムが用意されているため, 少ない手数で扱える。

    – メッセージングシステム – ドッキングシステム – ショートカットシステム • Python で書けるので効率が良い。 – コンパイルする必要がないので,GUI の調整⇔動作確認が非常に速い。 – 豊富なパッケージと組み合わせて利用することが出来る。 • Maya 等の DCC ツールでも採用されているため,ノウハウの共有が出来る。 – DCC ツールは Python 2系なので,コードの共有は難しいかもしれないが…
  31. Qt for Python のあまり嬉しくない点 67/91 • ドキュメントが不足している。 – 引数の型が書かれていないため,C++ 用のドキュメントで確認する必要がある。

    • クラッシュしたときにデバッグがしづらい。 – クラッシュの原因が Qt 内部だった場合に,原因の特定が非常に難しい。 • Qt for Python ならではの不具合が存在する。 – 特に困ったのが @QtCore.Slot デコレータを設定すると,特定の条件下でメモリ破壊が 発生することがあるという不具合。 • https://bugreports.qt.io/browse/PYSIDE-249 – Cyllista Game Engine では @QtCore.Slot デコレータは使用禁止に。
  32. GUI 事例 -グラフビューワ- 68/91 • エフェクトグラフやアセット依存関係図などで使用。 • Qt Graphics View

    Framework を利用。 – 長方形や曲線など,様々な 2D アイテムを制御することが出来る。アイテムはクリック 等の各種イベントを扱うことが出来,マウスの動きも追跡することが出来る。
  33. GUI 事例 -ビューポート- 69/91 • ランタイムの画面を ウィジェット内に埋め込んでいる。 • 別プロセスのウィンドウを容易に ウィジェットに埋め込み可能。

    • マウスイベントの扱いは要注意。 – 別プロセスのウィンドウ上のマウスイベントは埋め込み先に通知されない。 – 別プロセス上でコンテキストメニュー等を表示したい場合は nativeEvent() で Windows のメッセージを拾って制御する。 window_wrapper = QtGui.QWindow.fromWinId(hwnd) window_widget = QtWidgets.QWidget.createWindowContainer(window_wrapper) self.layout().addWidget(window_widget)
  34. GUI 事例 -アセットエクスプローラ- 70/91 • アセット専用のエクスプローラ。 各エディタ上で使用する。 • QAbstractItemModel を継承して,

    アセット情報管理に最適化して実装。 – 数万アセット表示可能。 – サムネイル表示,詳細表示などの様々な 表示形式に対応。 • サムネイルの動画再生にも対応。 – アニメーションデータなどは動画で確認可能。
  35. テスト駆動開発(TDD) 72/91 • ランタイム同様にツールについても TDD を導入。 • TDD とは –

    プログラムに必要な各機能について,最初にテストを書き, そのテストが動作する必要最低限な実装をとりあえず行った後, コードを洗練させる,という短い工程を繰り返すスタイル – https://ja.wikipedia.org/wiki/テスト駆動開発
  36. テストツール 73/91 • unittest – Python 標準モジュール • nose –

    継承不要 – プラグイン機能 • pytest – fixture デコレータでテストで利用する リソースをテストごとに定義できる。 – プラグイン機能 – unittest/nose 用のテストを そのまま流用可能。 pip install nose pip install pytest
  37. テスト環境 75/91 • ローカル – CLI ツールで即時テスト可能。 • CI –

    公開されているテストを Jenkins 上で 巡回テストする。 – 全てのテストが通った場合に 安定版と認定する。
  38. python.exe のクラッシュ追跡 76/91 • pyd (Qt for Python や自作の pyd)

    を利用すると,python.exe が クラッシュすることがある。 – Python ソースコードのみを扱っている場合はクラッシュすることはまず無い。 • 主なクラッシュ原因例 – GIL を取得していない状態で PyObject を操作した。 – バッファオーバーランによるメモリ破壊。 – PyObject の参照カウントが正しく設定されておらず,参照カウントが 0 になった PyObject に対して書き込み処理を行った。
  39. クラッシュレポート 77/91 • python.exe がクラッシュしたときに 原因の調査が出来るように対応。 – dmpファイルを必ず出力する設定にする。 – dmpファイルの出力先ディレクトリを監視し,

    dmpファイルの出力が検知されたら, クラッシュレポートダイアログを立ち上げる。 – デバッグシンボル (pdb) もツールと合わせて 配布しておくことで,コールスタックも表示できる。 – 状況などを書き込んでもらって送信してもらう。
  40. ソースコード自動フォーマット 78/91 • PEP8 準拠 – 自動整形やソースコードチェックに使用するモジュール群。 • PEP8 準拠のソースコードになっていない場合は

    Perforce に submit 出来ないようにしている。 – Perforce のトリガー機能を利用。 pip install autoflake pip install autopep8_ pip install flake8___
  41. ドキュメント生成 79/91 • Sphinx を利用。 – docstring 規格に合わせて関数ごとにドキュメントを書き,Sphinx を実行すれば Web

    等で参照可能なページが出来上がる。 – Cyllista Game Engine 用にカスタマイズして,目次などを見やすく改善。 pip install Sphinx def info(text: str, inspect_depth: int = 0, once: bool = False) -> None: """ :param text: :param inspect_depth: :param once: """
  42. 型アノテーション 80/91 • Python は動的型付け。 – とても便利だが,これが理由で不具合も多々発生。 • 想定していない型の変数が入り込んできて,不正な挙動を起こす 等。

    • 実行されて初めてエラーに気付く。 • 型アノテーションを利用する。 – 変数や関数の返り値などに型ヒントを付ける。 – mypy を利用すれば,間違った型を検知することが出来る。 • あくまで静的解析による検知だけで,実行時にエラーになるわけではないので注意。 • http://www.mypy-lang.org/ pip install mypy
  43. 型アノテーション 81/91 • 例えば a と b という2つの変数を加算した値を返す関数の場合 – 両方

    int が入力されれば問題ないけれど,int と str が入力されたら例外が発生する。 – 引数と返り値に型ヒントを付ける。 – mypy を実行すると事前にエラーとして検知できる。 def add_int(a, b): return a + b add_int(1, 2) # 3 add_int(1, 'a') # TypeError def add_int(a: int, b: int) -> int: return a + b error: Argument 2 to "add_int" has incompatible type "str"; expected "int"
  44. 型アノテーション 82/91 • typing モジュールを利用することで,様々な型ヒント設定が可能。 – typing.Union[X, Y] • X

    または Y を表す。 – typing.Optional[X] • X または None を表す。typing.Union[X, None]同等。 – typing.Sequence • 整数インデックスで要素アクセスできるものを表す。str,list,tuple 等。 – typing.Iterable • for ループで扱えるものを表す。str,list,tuple,dict,set 等。
  45. ツールアップデータ 84/91 • ツールを更新するためのアップデータも Python で出来ている。 – 自分自身も更新する必要があるので厄介… • アップデータは

    PyInstaller で exe 化している。 – https://www.pyinstaller.org/ – exe 化しているので,アップデータのソースコードが更新対象に入っていても問題なし。 pip install PyInstaller
  46. 自動リロード 86/91 • ソースコードの修正を即時反映させるために,モジュールのリロード処理を 自動的に行うようにする。 – importlib.reload() • 通常の import

    でインポートしたモジュールに対して利用。 – __loader__.exec_module() • 下サンプルのように importlib モジュールを利用してインポートしたモジュールに対して利用。 def load_python_module(module_name: str, module_path: str): import importlib.machinery import importlib.util module_loader = importlib.machinery.SourceFileLoader(module_name, module_path) module_spec = importlib.util.spec_from_loader(module_name, module_loader) module_instance = importlib.util.module_from_spec(module_spec) module_spec.loader.exec_module(module_instance) return module_instance
  47. 自動リロード 87/91 • 依存しているモジュールも走査し,変更が検知されたら自動リロードを行う。 def get_imported_module_list(module_instance, include_system: bool = False,

    module_list: Set = None): import inspect if not hasattr(module_instance, '__file__'): return module_list if module_list is None: module_list = {module_instance} member_infos = inspect.getmembers(module_instance, lambda m: inspect.ismodule(m) or inspect.isclass(m) or inspect.ismethod(m)) for info in member_infos: # Module Instance depend_module: Optional[ModuleType] = inspect.getmodule(info[1]) if depend_module is None: continue # if depend_module in module_list: continue # if not include_system: module_file = getattr(depend_module, '__file__', None) if module_file is None: continue if 'python37-64' in module_file: continue # import module_list.add(depend_module) module_list = get_imported_module_list(depend_module, include_system, module_list) return module_list
  48. 利便性の高いツールを作るには… 89/91 • ツール開発者の効率を上げる! – Python は対話型のため,開発イテレーションが速い。 – 技術関連の情報も溢れているので,調査コストが低い。 –

    独自のモジュール化することで,汎用的に使い回すことが出来る。 • 多種多様な技術を積極的に取り入れる! – サードパーティー製のパッケージがすぐに試用できる。 – Qt for Python を活用して GUI 開発。 – 外部ツール用のパッケージをインストールすれば,簡単に連携可能。 • ツールの配布を容易にする! – Perforce で Python のソースコードを配布するだけで OK。