Programming in VRChat

VRChat でのプログラミングについて調べたことの書き溜め

Toybox ObjectPool

Toybox に含まれる ObjectPool プレハブの使い方、内部の仕組みなどを解説する

(文章校正がまだ十分にされていません。不可解な点があればお知らせください。)

この文書に従って作成できるものを デモワールド wrld_c8f68baa-6d46-483d-9c39-9b822cacd4e1 として置いてあります。

Object pool とは

ソフトウェア設計における一般的な手法の一つ。

プログラムが実行されている期間(VRChat の場合はワールドインスタンスの維持期間)に対して、 あるオブジェクトの使用期間が限られていて、かつそれが頻繁に多数使われる場合に、 それらオブジェクトの生成と破棄の処理を軽減するための手法。

そのオブジェクトの存続の必要がなくなった時に、 実際の破棄はせずに溜めておく場所「プール」に移しておき、 次に新たなオブジェクトが必要になった時にこのプールから取り出して再初期化して使用する、 というのが基本的な仕組みである。

Toybox について

Toybox は作者 Hardlight680 さんによるアセット集であり、 VRChat のワールドを開発する際に有用なプレハブおよびシーンを配布しているものである。

  • Readme.txt より: "A collection of useful prefabs and scenes for use in developing VRChat worlds."
  • 入手元は作者によるツイートを参照: https://twitter.com/JasonL663/status/1012799892425588736
  • (ObjectPool プレハブでは必要ないが、 Toybox は Unity の Standard Assets および UnityChan パッケージを利用している。 他の機能を使う場合はそれも合わせて導入すること。)

用語 spawn, despawn

Toybox ObjectPool はオブジェクトを取り出す操作を「spawn(スポーン)」、 用が済んだオブジェクトを ObjectPool のプールに戻す操作を「despawn(デスポーン)」と呼んでいる。

VRChat SDK には SpawnObject というアクションがあるが、 この文章での「spawn」はそれではなく ObjectPool の用語を指している。

( 両方とも「シーン上にオブジェクトが出現する」という点では同じだが、内部的には扱いが異なる。 SpawnObject アクションでは GameObject が新規に生成されているのに対し、 ObjectPool の spawn では GameObject は再利用されており新規には生成されていない。 この点、用語の意味を取り違えて混乱しないで欲しい )

基本的な使い方

ここでは基本的な使い方を示すために、例として、 キューブをインタラクトするとオブジェクトがスポーンし、 スポーンしたオブジェクトをインタラクトするとデスポーンする、 という構成を作る。

作成手順

  1. ObjectPool を配置する
    • Toybox の ObjectPool プレハブをシーンに追加する。( プレハブの位置は Assets/Toybox/ObjectPool )
  2. Pooled object を一つだけ残して削除する
    • (ObjectPool プレハブは初期状態で例示として3つのオブジェクトを扱う状態で構成されている。 以下に述べる手順でオブジェクトを改造するので、一つだけ残してあとは削除する。改造の後に再び複製して増やす。)
    • ObjectPool/Instances/PooledObject (2) および ObjectPool/Instances/PooledObject (3) を削除する
    • 削除操作の際 "This action will break the prefab instance." という警告が出るが続行する
  3. Pooled object に対する物理設定を調整する
    • ObjectPool プレハブの初期状態で都合が悪い点があるので調整する。 いくつかの方法がありうるのだがここでは簡単にするため、ワールドの設定の方を変えてしまう。)
    • VRC_SceneDescriptorObjectBehaviourAtRespawnHeightRespawn にする
    • VRC_SceneDescriptorObjectPool の中ではなくシーンに一つ置いてあるはずのもの)
  4. 生成したいオブジェクトの作成する
    • ObjectPool/Instances/PooledObject (1)/Object/Sphere が ObjectPool で管理されるオブジェクト (のゲーム上で現れる部分)である。ここを出現させたいものに作り替える。 (ObjectPool の動作を試してみたいだけならばプレハブに入っている状態のまま先に進んでよい)
    • 名前は Sphere のままにしておくこと
    • 作成し終わったらオブジェクトを元のようにインアクティブにしておくこと
  5. オブジェクトがスポーンする場所を設定する
    • ObjectPool/SpawnPoint をオブジェクトをスポーンさせたい場所に移動する
  6. スポーン操作を実装する
    • Cube などを作り VRC_Trigger を追加し OnInteract トリガーを追加する。
    • VRC_TriggerAdvancedMode にチェックを入れ、broadcast type を Local にする
    • Action に AnimationTrigger を追加する。Reciever に ObjectPool/Spawner を指定する。Trigger 名に Spawn を指定する。
  7. デスポーン操作を実装する
    • 前ステップの SphereVRC_Trigger を追加、OnInteract トリガーを追加する。
    • VRC_TriggerAdvancedMode にチェックを入れ、broadcast type を Local にする
    • Action に ActivateCustomTrigger を追加、receiver にSphereから見て二つ上の PooledObject (1) を指定。 Custom trigger 名に Despawn を選択する。
  8. 必要なだけオブジェクトを増やす
    • ObjectPool/Instances/PooledObject (1) を duplicate して、Instances の下に必要な数のオブジェクトを持たせておく。

補足

  • 物理設定の調整について
    • ここではシンプルな実装になるようにワールドの設定 ObjectBehaviourAtRespawnHeight を変更する方法を紹介した。
    • これはObjectPool の初期状態ではオブジェクトが使われていない時に、 落下していって失われてしまうために必要な措置となっている。 別解として「ObjectPool/Instances/PooledObject (1)/Object が持つ RigidbodyIsKinematic を有効にする」 あるいは同様に「UseGravity を無効にする」といった方法で Respawn Height に容易に達しないようにするという方法も取れる。 だがそれらの場合、デモンストレーションとして見た目が分かりにくいので、ここでは前述の方法を採用した。

Pickup でき、かつ gravity enable なオブジェクトと共に使う例

ObjectPool で「ピックアップでき重力によって落下する」つまりは VR の中で自然な振る舞いをする手に持てるオブジェクトを扱えるようにする手順を述べる。

例として 「ワールドにひとつ配置した Cube のインタラクトにより spawn し、オブジェクトが特定 Layer に触れると despawn する」 ものを作る。 Layer には Water を使用する。(デフォルトで存在するレイヤーの一つというだけで、選定に深い意味はない。)

なお、ここで作成する構成は、ObjectPool の元の状態を生かして作業量が少なくて済むようなものにしている。 用途によっては必ずしも最適なものではないかもしれない。

作成手順

  1. ObjectPool の配置
    • Toybox の ObjectPool prefab をシーンに追加する。
    • ObjectPool/Instances に入っている pooled object を一つだけ残してあとは一旦削除する。(以下 PooledObject と表記) "This action will break the prefab instance." という警告が出るが続行する
    • (ここでデバッグのために目印として PooledObject/Object/Sphere の兄弟にオブジェクトを追加しておくと良い。 このオブジェクトのコライダーは邪魔になるので削除しておくこと。 ObjectPool prefab の機能により PooledObject/Object が空間を移動するのだが、 Sphere が初期状態では inactive なので何処に位置しているのか判断できない。 デバッグ用に見えるオブジェクトを入れておくことで作業がしやすくなる。)
    • ObjectPool/SpawnPoint オブジェクトを、シーン上でオブジェクトが出現する位置に移動する
      (必要に応じてこれにもデバッグのために目印として子オブジェクトを作成しておくと具合が良い。 追加する場合はコライダーは邪魔になるので削除しておく。)
  2. Spawn 動作をさせるスイッチを作る
    • Cube を作り VRC_Trigger を追加し OnInteract トリガーを追加
    • Advanced Mode にチェックを入れて broadcast type を Local にする
    • action に AnimationTrigger を追加 reciever に ObjectPool/Spawner を指定する。
    • trigger 名に Spawn を指定する
  3. コライダーの設定
    • (この段階でスポーン動作は既にするようになっている。ただし PooledObject/Object は初期状態で有効なコライダーを持たないので落下してしまっている。その対策をする)
    • PooledObject/Object にコライダーを追加する。
      • PooledObject/Object/Sphere の見た目の形状にちょうど合うようにする。Sphere を 一旦 active にして表示を見て確認すると良い。
      • 配布状態で Sphere の scale が 0.25 SphereCollider は大きさを半径で指定するのでその半分、よって Radius を 0.125 すると見た目と一致する。
    • PooledObject/Object/Sphere についているコライダーは不要になるので削除する。
  4. VRC_Pickup と Rigidbody を調整
    • PooledObject/ObjectVRC_Pickup はあらかじめ備えられているが Pickupable が無効になっているので有効にする。必要に応じて他の設定も変更する。
    • 同じく Rigidbody が備わっているので、パラメタを必要に応じて変更する。
  5. Despawn のきっかけとなる衝突対象オブジェクトを作る
    • (ここまでで spawn して使えるようになるための処置は完了している。以降では despawn の処理を作っていく)
    • シーンに適当なオブジェクトを生成する
    • コライダーの IsTrigger を有効にする。 LayerWater にする。
  6. Despawn 動作をさせるトリガーを設定する
    • PooledObject/ObjectVRC_Trigger を追加し OnEnterTrigger トリガーを追加。
    • Advanced Mode にチェックを入れて broadcast type を MasterUnbufferd にする
    • LayersWater を選択する
    • Action に ActivateCustomTrigger を追加する
    • Recievers にこのオブジェクトの親である PooledObject を追加する。Name に Despawn を選択する。(Deactivate を選ばないように)
  7. Pickup 中に despawn した時の処置を追加
    • (持っている時に despawn が発動するとアバターの手が持ったままになるので、対策する)
    • PooledObjectVRC_TriggerDeactivate という custom trigger をインスペクタで開く。
    • もとからある SetGameObjectActive アクションの前に SendRPC アクションを加える。
    • Recievers に PooledObject/Object を選択する。
    • Method の選択肢の中に VRC_Pickup.Drop があるので選ぶ
    • Targets を Local にする。(その後ろの "Use Player ID as last" はそのままチェックが入った状態にしておく)
  8. Despawn されて再利用待ちになっているオブジェクトを格納しておく場所を作る
    • (ここまでで despawn 処理自体は行えるようになっている。ただし PooledObject/Object/Sphere は inactive に戻り、 見えなくなっているが PooledObject/Object はその場にとどまっている。 PooledObject/Object の pickup 機能はそのままなのでつかめてしまう。以降ではこの処置をする。)
    • PooledObject/Object が衝突して出られない囲われた空間を作る
  9. この空間に位置するように格納場所を指すオブジェクトを作る
    • (移動には VRC_ObjectSync.TeleportTo を使うのだが不具合があるので親子構造を余分に作る)
    • Create Empty で空のオブジェクトを作る。以降 PoolPointHolder と呼ぶ。
    • PoolPointHolder にさらに空のオブジェクトを作る。PoolPoint と呼ぶ。
    • (さらに ObjectPool/SpawnPoint の時と同じように、コライダーを無効にした目印オブジェクトを入れておくと分かりやすい)
    • PoolPoint の Trasform はゼロにしておき、 PoolPointHolder の位置を前手順で作った空間の空中に位置するように調整する
  10. despawn 時に前述の空間へ移動させる
    • PooledObjectVRC_TriggerDeactivate という custom trigger をインスペクタで開く。(Drop を追加したのと同じ所)
    • もとからある SetGameObjectActive アクションの後に SendRPC アクションを加える。
    • Recievers に PooledObject/Object を選択する。
    • Method の選択肢の中に VRC_ObjectSync.TeleportTo があるので選ぶ
    • Targets を Owner にする
    • targetLocation に、前の手順で作成した PoolPoint を指定する
  11. PooledObject の初期位置を前述の空間内にする
    • despawn 時と同じになるように PooledObject の初期配置位置を前述の空間内にする
  12. オブジェクトの個数を調整する
    • (仕組みはこれで出来上がっているので、動作確認をまずする。問題ないならば最後の調整を行う)
    • デバッグ目的でいれた目印オブジェクトを削除するか inactive にする。
    • PooledObject を必要な個数だけ duplicate する。
    • (コライダを持っているので同じ座標に置くと互いに相手をはじく。できれば重ならないように配置したほうが良い。数個程度なら大丈夫ではある)

注意事項

  • 表示に用いているオブジェクト Sphere は内部の構成は変えて良いが、この名前を不用意に書き換えてはいけない。 (変えたい場合は PooledObject が使用するアニメーションクリップ Assets/Toybox/ObjectPool/Animation/PooledObjectActive の中でこの名前で参照しているので、そこも合わせて変更すること。)
  • VRC_Trigger での VRC_ObjectSync.TeleportTotargetLocation の指定は奇妙なふるまいをするので注意。
    • target に指定するオブジェクトの調整は先に済ませ、targetLocation に設定するのは最後にした方がいい。
    • (例えば名前を後から変更すると VRC_Trigger が見失ってしまう)
  • spawn/despawn の指示(すなわち animation trigger Spawn の有効化、Despawn custom trigger の呼び出し)は ひとりのプレイヤー(一つのクライアント)でのみ行うようにしなければならない。 (他への伝達は ObjectPool が実装しているので、それへの指示自体を伝達すると処理が過剰に同時に実行されてしまう。)
    • despawn では PooledObject/Object のオーナーによって(broadcast type を Owner にする事で)一人に絞れる
    • spawn はこのガイドでは OnInteract を使用したので broadcast type を Local にした。
      • ワールドに居る各プレイヤーが等しく観察するトリガーではこの部分を作成するのが少々難しいかもしれない。
      • spawn の場合は複数から同時に呼ぶと生成する数が

残っている問題

  • 複数のプレイヤーが同時にスポーン動作をさせると一つしか現れないことがある。
    • (ObjectPool にもとからある問題 Toybox/ObjectPool/Readme.txt 参照 )
  • 全てのオブジェクトが出払っている場合にスポーンが失敗した時の動作。
    • 現状では何も起きない。オブジェクトが現れなかったことでしか把握できない
  • オブジェクトがワールドから落下した場合の振る舞い
    • VRC_SceneDescriptorObjectBehaviourAtRespawnHeightDestroy になっている場合、 ObjectPool で管理しているオブジェクトが一つ失われてしまう。
    • Respawn にするとどうやら初期位置に戻るようだ spawn 状態のまま格納庫に入ってしまうので具合が悪い。(状態は異なるので選別処理は可能ではあろう)

解説

  • Toybox ObjectPool は spawn 時にオブジェクトを所定位置に移動させるまでの面倒をみようとする設計になっている。 このため PooledObject/ObjectVRC_ObjectSync を備える。 また、VRC_ObjectSync を確実に動作させるために Pickable false な VRC_Pickup を備え、 この VRC_PickupRigidbody に依存している。
  • ピックアップ可能なオブジェクトを Toybox ObjectPool で管理したい場合、 この既に備わっている VRC_Pickup および Rigidbody を利用する必要がある。

    • 独自のものを下位構造として作ってもおそらく動作はするが、その内容は ObjectPool が行っている位置管理と同程度になり、 ObjectPool を使う事で楽をしようとしているはずなのに本末転倒なことになり、無意味である。
  • ObjectPool は管理しているオブジェクトが「活動している」ことを表すのに、PooledObject/Object/Sphere の isActive を使用している。

    • 言い換えると「spawn/despawn に際してオブジェクトの選定、位置移動、状態変更までの面倒は見るので、 他のことは利用者が Sphere 以下に実装する」という設計になっている。
  • 以上の Toybox ObjectPool の構成のもとで、pickup 可能なものを作ろうとするとやや困った事になる。

    • ObjectPool 利用者のオブジェクトは PooledObject/Object/Sphere 以下に作らせる設計なのに、 肝心の VRC_Pickup は一つ上の階層 PooledObject/Object に既に存在しているからである。
    • このガイドでは、既に備わっているコンポーネントを(Pickable を true に変更するなどしつつ)そのまま使う方針を取った。
  • RigidbodyUseGravity を有効にした場合、コライダをどう設定するかが問題になる。

    • 本来は目に見えるオブジェクトである PooledObject/Object/Sphere の形状に合わせて、このオブジェクト以下でコライダを付けたい。
    • だが despawn 時 には Sphere は inactive になるためここに設定したコライダは働かない。 UseGravity が有効なオブジェクトを扱う場合、このままでは PooledObject/Object は落下していってしまう。
    • また(UseGravity に関係なく)VRC_Pickup の動作のために PooledObject/Object はコライダを備えておく必要がある。
    • 理想的な構成は以下だろう
      • PooledObject に pickup のためのコライダ(IsTrigger は true)
      • PooledObject/Object に物理的形状のためのコライダ
      • 初期 と despawn 時は Rigidbody.IsKinematic 有効にして物理演算の影響を受けないようにする
    • だがこれは現状(ver 2018.2.2)で安定動作しない。
      • Rigidbody.isKinematic の書き換えが安定動作しない。 VRC_ObjectSync がこの部分に関与している模様。 uGUI から VRC_ObjectSync.isKinematic も見えるが、この変更もうまくいかない。)
    • 代替策としてこのガイドでは PooledObject/Object に備えたコライダを使い続けるようにした。
      • これに伴い despawn 時にオブジェクトを保存しておく場所を作りそこに移動する処置を追加した。
      • VRC_ObjectSync.TeleportTo には rotation に関して不具合があるが、今回の場合は問題ない。
  • 結果的に Sphere は見た目だけを担い rigidbody や コライダの機能は PooledObject/Object の方が担うことになった。
    • 常に active で良いのであれば、 PooledObject/Object から作りたいオブジェクトを構成してもよいだろう。(Sphere の存在は無視する)
    • Sphere では OnEnable のみをしかけて初期化処理の目的で使う、といった構成の仕方もありうるだろう。

仕様・注意事項

これまで述べたものと重複するが、リファレンスとして仕様を書き出してみる。

概要

  • Toybox ObjectPool は VRChat SDK 環境下において object pool を実現するプレハブである。
    • ObjectPool は一定個数のオブジェクトを管理し、それらを再利用する spawn 、despawn 操作を提供する

セットアップ

  • spawn されるオブジェクトは ObjectPool/Instances の下にあらかじめ格納しておく
    • ここに格納されている PooledObject が個々のオブジェクトを管理する単位である。
    • プレハブでは例示として元々3個格納されているが、 必要に応じて削除・複製をして数を調整する。
  • spawn/despawn によってシーンの3D空間を移動する GameObject は PooledObject/Object である。
    • ここに保有されているコンポーネントは ObjectPool の動作に必要なものなので削除してはいけない。パラメタも不用意にいじらない事。
    • 物理特性を変えたい場合はここの Rigidbody に設定する
  • spawn/despawn されるオブジェクトの外見は、PooledObject/Object/Sphere 以下に作る
    • 初期状態ではこの GameObject は inactive にして表示されないようにしておくこと。
    • この "Sphere" という名前は変えてはいけない。(この名前で参照している AnimationClip があるため。変えたい場合はそちらも合わせて変更する)
  • ObjectPool/SpawnPoint はオブジェクトがスポーンする位置
    • SpawnPoint の Transform の Scale は 1.0 のままにしておくこと。 (変えても動作に支障はないが 1.0 が作成作業において面倒がない)

操作

  • spawn
    • ObjectPool/Spawner のアニメーション・トリガー Spawn を引くと、オブジェクトが SpawnPoint の位置にスポーンする。
    • このアニメーション・トリガーの操作はローカルな操作で行う事。さもないとインスタンスに居るプレイヤーの数だけスポーンしてしまう。
  • despawn
    • PooledObject の Custom Trigger Despawn を実行する。

コールバック

  • spawn, despawn 時に行いたい処理を追加するには、 PooledObject/Object/SphereVRC_Trigger を追加し OnEnable, OnDisable トリガーとして書く。 broadcast type は Local にすること。

既知の不具合

  • ほぼ同時に複数のプレイヤーが spawn 操作を行った場合、spawn されるオブジェクトの数が足らないことがありうる。

内部の仕組みについて考察

プール構造の実現

  • VRChat SDK では通常のプログラミング言語で使用する変数に相当するものが自由にならないため、 そもそもプールするオブジェクトをどのように保持管理するのか、というのが問題(見どころ)となる。
  • Toybox ObjectPool では、この集合構造にオブジェクトの親子関係を利用している。 親子関係は Transform.SetParent(parent: Transform) によって変更でき、これは uGUI の機構から呼び出し可能である。
  • 「集合のうち一つのオブジェクトを対象に操作する」というのが問題になるが、 これには「Unity の AnimationClip での対象オブジェクトの指定は名前に頼っている。 同名の物が有った時に一つだけが処理される。」という仕様が活用されている。
    • (「一つだけ」の部分の仕様書からの裏付けは取れていないが、見るかぎりそうなっている)
  • この構成を取った場合に実装上の鍵となるもう一つの関数は Animator.Rebind() SetParent で書き換わった構造を Animator に理解させることができる。

アーキテクチャ

  • Spawner は自身では pooled object の(論理的な)保持と選択だけを行っている。 spawn/despawn の実処理は個々のオブジェクト側に書いてある。
    • 「処理対象が一つ選ばれる」はこの前半の処理である Spawner で行うのだが、 実際の構造変更は後半で個々のクライアントで実行される。(一般的なプログラミングに成れていると奇妙に感じるところかもしれない)
  • 前半処理は要求を出したマシンローカルで実行され、 pooled object の外部インタフェースになっている custom trigger(Spawn, Despawn)によって処理が全クライアントに配られる。 そこから先はまた各マシンローカルになっている。
    • 処理の分岐としては基本的にはここだけになっていて、同期(というか分散処理)の考え方が明確になっている。
    • (つまり若干意外なことに)プール構造の変更は「個々のクライアントが同時に行うので結果的に同じ状態になる」方式の同期になっている。
    • object pool において各オブジェクトは最終的に active か inactive なので、バッファは AlwaysBufferOne を使用している。
    • late joiner に対してはこの記録が再生されることで、各 pooled object が自主的にプールに留まったり出てきたりして、 同じ状態が再現される。(繰り返しになるが、集中的な管理を行わずに個々に自主的にやらせるという方針は、なかなか器用だ。)
  • 外部インタフェースと内部処理の間にはアニメーション機構が挟まっていて、ここで pooled object の状態遷移管理をしている。
    • (繰り返しになるが)これは個々のクライアント内での状態遷移の表現であり、 この Animator を保持する PooledObject 自体は同期処理されていない。 Custom トリガー(のアクション)を配ったことでマルチクライアントの処理は済んでいる。
    • (スポーンしたオブジェクトの空間移動については PooledObject/Object が持つ VRC_ObjectSyncSynchronizePhysicstrue になっていることで実現される。 そのことについて基本的には PooledObject 自身は関与していない。
  • 種々の処理は、それぞれの処理ごとにオブジェクトを用意して整理している。
    • 処理はオブジェクトの有効化をきっかけに開始される。
      1. VRC_Trigger で処理を書く場合は OnEnable トリガーに書く
      2. AnimationClip で書きたければ Animation を PlayAutomatically にして自動的に再生開始
      3. uGUI で書く場合はそこからさらに animation event で OnClick() を駆動
    • オブジェクトは自身で不要になった時に無効化して、次の実行に備えている。
    • サブルーチン的なものは「別のオブジェクトを有効化する」ことで実現される。
    • (この方式は同じことを別のオブジェクトで実行したい場合は、オブジェクトを丸々コピーする必要があり空間効率が悪い。 だが VRChat trigger システムの記述では操作対象をパラメタとして書けず 「VRC_Trigger が付随しているこのオブジェクト」としてしか表記できないので致し方ない。)
  • 内部処理は uGUI を使い Unity のコンポーネントの世界で書かれる。

同時処理について考察

  • 二人がほぼ同時にスポーン操作した場合、オブジェクトは一つしかスポーンしない。
  • これは同じ PooledObject がそれぞれのマシンで処理対象として選ばれてしまうからである。
    • それぞれの世界での Spawner/Handle/Spawn 先頭に居たものは同じものになっているため。 この時、個々のクライアントは自分が spawn 処理始めたものだとして振舞っているが、実は同じ Handle/Spawn を叩いている。
  • すると Custom Spawn は同じオブジェクトに二回発せられる。 しかし構造が壊れてしまうような致命的な問題は起きない。 これは PooledObjectAnimator アニメーション・コントローラの遷移処理によって、 activate 処理は各クライアントで一回しかされないようになっているためである。

まとめ

  • アーキテクチャを整理してみると以下のようになっている事が分かる
    1. 処理の受付部分(受け付けたマシンローカルで実行する。ローカルで行っておく下処理を含む)
    2. 外部インタフェース(Custom trigger を適切な broadcast type で用意し、ここの呼び出しにより要求を各マシンに配る)
    3. 状態遷移管理(アニメーション・パラメタによりオブジェクト状態を扱う、必要な内部処理の呼び出しを行う)
    4. 内部処理(可能な限り uGUI を使用して、Unity の世界で書く)
  • この階層構造により堅牢かつ改造が容易なアーキテクチャになっている。

付録

構造図:

f:id:naqtn:20180821122757p:plain