なねぃと調査

こんばんわ。Pocolです。
もうすぐSIGGRAPHですね。SIGGRAPHのコースで「なねぃと」についての話があるそうで,そのコースを受ける前にある程度は調査しておこうと思いました。Sさん、問題あればご連絡を。

個人的に知りたいこと

今回の調査では下記のようなことを知りたいなと思ったので,調査してみました。

  • どんなレンダリングフローなのか?
  • 実際にどんなシェーダ使っているの?
  • どうやってストリーミングするものを決めてるの?
  • ストリーミングデータはどうやって作るのか?

大雑把な流れ

なねぃとは仮想化マイクロポリゴンジオメトリシステムです。いわゆるVirtual Textureみたいなテクスチャストリーミングのメッシュ版という感じのやつです。細かいポリゴンを扱えるのが売りになっていて,ものすごくいい感じのディテールが表現できます。
なねぃとを実現するためのキーだと思っているものは次の通りです。

  • GPU駆動描画
  • 高速なソフトウェアラスタライズ
  • Deferred Material(Visibility Buffer)
  • トライアングルデータの圧縮
  • 階層LODの構築

まずGPU駆動描画はその名の通り,GPU上で描画するかどうかを判定を行い,その結果で描画が駆動する手法のことを言ったりします。これはカプコンさんだったり,アサクリだったり,Trialsだったりと色々な会社さんがすでに取り組まれています。内容について知らない方がいたら下記の資料などを読むと良いと思います。

UE5はバウンディングのスケールに応じてソフトウェアラスタライズとハードウェアラスタライズの分岐がコンピュートシェーダ上で決定されます。
小さな三角形はコンピュートシェーダを用いたソフトウェアラスタライズが実行され,大きな三角形に対してハードウェアラスタライザが実行されます。
ソフトウェアラスタライザですが,小さな三角形に対しては平均で3倍高速化したと「Nanite | Inside Unreal」の動画でKarisが言っていました。恐るべき速度ですね。

※図は”Nainte | Inside Unreal”より引用

あとは,この高速なソフトウェアラスタライズを支える技術として,Deferred Materialを使用しています。いわゆるVisibility Bufferというやつです。
これの何が良いかというと,深度とマテリアルインデックスをバッファに出力してしまい,マテリアル評価を遅延実行できるというメリットがあります。つまり,いちいちシェーダの切り替えをしなくて良いということです。これによりUE5は不透明物体の描画を1ドローで実現しています。

※図は”Nainte | Inside Unreal”より引用

さらにNaniteを見ていてがんばっているなーと思うのがデータの圧縮です。1トライアングルにつき平均14.4Byteだそうです。一応自分でもソースコードを追ってみて,確かにそうだなということを確認しました。

※図は”Nainte | Inside Unreal”より引用

どんなレンダリングフローなのか?

まずは,レンダラーの流れをつかみ大雑把にどんなことをやっているのかを把握してみました。
レンダラーの実装は,Engine/Source/Runtime/Renderer/Private/DeferredShadingRenderer.cppにあります。これがディファードレンダリングの実装になっており,Render()メソッドに実装があるので,これを地味に読み解いていきました。なんとこの関数1600行ほどあります。

この関数をところどころを端折った疑似コードが下記のような感じになります。

ざっくりですが,処理をまとめておくと

  • グローバルリソースを更新
  • 非同期でファイル読み込みを開始
  • 読み込みタスクの完了待ちをして,GPUにデータを転送
  • Visibility Bufferをクリア
  • ビュー情報をパッキングしてカリングとラスタライズを実行
  • 深度バッファに出力
  • マテリアルごとにドローコールを発行して,タイル描画を行うことによりG-Bufferを構築

…という感じです。

実際にどんなシェーダ使っているの?

CullRasterize()

下記のシェーダが実行されるようです。

  • InstanceCull :パス Engine/Private/Nanite/InstanceCulling.usf
  • InitArgs :パス Engine/Private/Nanite/ClusterCulling.usf
  • InstanceCullVSM :パス Engine/Private/Nanite/InstanceCulling.usf
  • PersistentClusterCull : パス Engine/Private/Nanite/ClusterCulling.usf
  • CalculateSafeRasterizerArgs : パス Engine/Private/Nanite/ClusterCulling.usf
  • HWRasterizerVS : パス Engine/Private/Nanite/Rasterizer.usf
  • HWRasterizerPS : パス Engine/Private/Nanite/Rasterizer.usf
  • MicropolyRasterize : パス Engine/Private/Nanite/Rasterizer.usf

左側は実行される関数で,右側はその関数が実装されているファイルパスを表します。
PersistentCull()がおそらく一番巨大なシェーダコードになると思うのですが,クラスタ階層のトラバーサルとかカリング,あとはソフトウェアラスタライズかハードウェアラスタライズかどうかの切り分けなんかも行っています。
MicroPolyRasterize()がソフトウェアラスタライズを行っているのですが,R32G32のバッファに書き込みをします。Rチャンネルの25bit分がVisibleIndexで,残りの7bitがTriangleIDになっています。GチャンネルはDepthに割り当てられているようです。

EmitDepthTargets()

下記のシェーダが実行されるようです。

  • DepthExport : パス Engine/Private/Nanite/DepthExport.usf
  • EmitSceneDepthStencilPS : パス Engine/Private/Nanite/ExportGBuffer.usf
  • EmitSceneSStencilPS : パス Engine/Private/Nanite/ExportGBuffer.usf

名前通りの処理っぽいです。

DrawBasePass()

下記のシェーダが実行されるようです。

  • ClassifyMaterials : パス Engine/Private/Nanite/MaterialCulling.usf
  • FullScreenVS : パス Engine/Private/Nanite/ExportGBuffer.usf

ClassifyMaterials()の中では,VisibleなMaterialとMaterial Rangeを決定するようです。Material Rangeにはマテリアルのマスクビットが格納されています。
大雑把な理解として,描画対象となるマテリアルの矩形範囲を決めているようです。
FullScreenVS()では,決定したマテリアルの矩形範囲にあるタイルで,本当にそのマテリアルの描画必要かどうかを判定して,要らないところはNaNを頂点シェーダで設定して,タイルをカリングするという処理を行うようです。タイルカリングには5つのモードがあるようです。

ストリーミングの仕組みは?

ストリーミングの管理はFStreamingManagerというクラスで管理されているようです。
Engine/Source/Runtime/Engine/Public/Rendering/NaniteStreamingManager.hにクラスの宣言があります。
・クラスタページデータ
・クラスタページヘッダ
・クラスタ修正更新バッファ
・ストリーミングリクエストバッファ
・ストリーミングリクエストリードバックバッファ
・ペンディングページ
・リクエストハッシュテーブル
などを保持しているようですが,まだ理解しきれていないので,理解できるようになったら別の記事として書くことにします。
ページリクエストは,PersistentClusterCull()というシェーダが実行されてRequestPageRange()という関数内でリクエストが書き込まれるようです。
このシェーダで書き込んだページリクエストの取得はディファードレンダラー内のAsyncUpdate()内でバッファをmapすることでCPU側で取得されるようです。
リクエストハッシュテーブルにGPUから取得したものを登録して,被るものがあるかどうかをチェック。チェックにヒットしたらストリーミングするページとしてプッシュし,優先度でソートして,LRUを更新しています。
一方,検索にヒットしない場合は,優先度付きリクエストヒープにいったんプッシュするようです。その後、このヒープからポップしてストリーミングするページを選択しています。
ストリーミングするものは読み込みされている状態なので,送ればいいのですが,これから読み込みしないといけないペンディング状態のものについては,ペンディング状態のページを収集し,ランタイムリソースIDがコンピュートシェーダで書き込まれるので,これを利用してFbyteBulkDataを取得するようです。取得したデータをFIORequestTaskに登録し,ParallelFor文を用いて,ファイルの非同期読み込みが実行さるようです。
ストリーミングマネージャのEndAsyncUpdate()という関数で,読み込み完了待ちをしてからResourceUplodTo()関数を使ってGPUに転送しているみたいです。

ストリーミングデータはどうやってつくるのか?

メッシュデータの構築

FStaticMeshBuilder::Build()経由でBuildNaniteFromHiResourceModel()が呼び出されて,データが作成されるようです。
BuildDAG(), ReduceDAG(), FindDAGCut()などの内部の処理がまだ全然理解できていないので,鋭意調査中です。

エンコード

ジオメトリデータはEncodeGeometryData()という関数でエンコードされるようです。
Positionデータは63bitに。
法線ベクトルはOctrahedronで表現し,XYで18bitに。
テクスチャ座標はXYで32bitに。
ここまで合計14byteになるので,確かにKarisが言っていた平均14.4Bという数字は納得できます。
ちなみに頂点カラーやUV数を増やす場合はさらにデータ容量が増えていく感じです。

おわりに

今回は,えらくざっくりですが,どんな感じで動くのかについて調査しました。
レンダリングフローについては理解できたのですが,実際のストリーミングの詳しい処理内容や,階層LODの作成方法など,まだまだわからない箇所もあるので次回調査してみたいと思います。

順調です。

こんばんわ、Pocolです。
アレですが,順調に進んでおります。機が熟したら書きますので、もう少しお待ちください。

資料:THE LAST OF US PARTⅡ

実装用の資料として,THE LAST OF US PARTⅡのスクショを張っておきます。動画も上げようと思ったのですが,サイズが大きくて上げられませんでした。

The Last of Us® Part II_20210124181203
The Last of Us® Part II_20210124181223
The Last of Us® Part II_20210124181237
The Last of Us® Part II_20210124181253
The Last of Us® Part II_20210124181308
The Last of Us® Part II_20210124181326
The Last of Us® Part II_20210124181344
The Last of Us® Part II_20210124181413
The Last of Us® Part II_20210124181429
The Last of Us® Part II_20210124181444
The Last of Us® Part II_20210324214828
The Last of Us® Part II_20210324214847
The Last of Us® Part II_20210324214923
The Last of Us® Part II_20210324215312
The Last of Us® Part II_20210324215326
The Last of Us® Part II_20210324215343
The Last of Us® Part II_20210324215354
The Last of Us® Part II_20210324215403
The Last of Us® Part II_20210324215601
The Last of Us® Part II_20210324215609
The Last of Us® Part II_20210324215620
The Last of Us® Part II_20210324215919
The Last of Us® Part II_20210324215930
The Last of Us® Part II_20210324215941

もうすこし待たれよ!

こんにちわ、Pocolです。
執筆している本ですが…,順調に進んでいます。

企画から5年。そして無名ということで企画が通るまでの,半年近くはほぼ何もできず。
担当さんの地道な根回しがあって,ようやく執筆までたどり着いた本です。

折角企画も通り,発売に向けて動き出したので…
発売後にマサカリ投げられるのは,嫌なので説明部分に関してはあらかじめ豪華メンバーに査読していただきました。
説明については丁寧にレビューしていただきました。
本当にこの場を借りて,感謝を述べさせていただきたいと思います。
「本業がありタイトル開発などお忙しい中,レビューアーの皆様本当にありがとうございました!」

実をいうと,レビューで心が折れました。
やっぱり,色々と見てもらうと自分がいかに愚か者であるかということをヒシヒシと感じますね。
説明の仕方もそうですが,言葉の選び方や言い回しなんかもそうです。
独りよがりはやっぱりいかんなと改めて思いました。
また、レビューしていただいて本当によかったなと心の底から感じています(本当最初の状態は酷かった)。
レビュー無しで,国会図書館なんかに寄贈されて,ダメダメな説明とかが自分が死んだ後も一生記録されてしまうと思うと,ゾッとしますね。
レビューが無かったら,そうした恥をさらしながら生きていかなければならないのですが,レビューをしてもらうことで,少しでも回避することができて本当によかったなと思います。
やはり,レビューをしてもらって感じたのですが,きちんとした議論があってこそ,良いものが生まれてくるんだろうなと… そう感じました。
自分が見ていて「これは良いな!」と思う書籍は共著だったり,きちんと監修が付いていたりしますしね。

自分は筆不精でして,期待されて待っている方には本当に申し訳ないのですが,
実はとある競合書籍が発売されていなければ,今頃は既に発売済みだったんですね。
何もせずに,当初通りそのまま発売というのもありだったのですが,単なる2番煎じになるもの,流石にどうかなと思いまして…。
(まぁ、だったら先越される前に早く書けよっていうのはごもっともです。)

やはり,先手を打ち出されてしまうと,後手側は何かしらの対策を練らねければなりません。
何も対策を練らないというのは愚の骨頂ではないかと思いましたし,何かしら付加価値を付けて提供したいと思いました。
また,何もせずにそのまま発売にもっていってしまうのは,色々な方の力を借りている分,自分として申し訳なかったですし,待っている方にも申し訳が立たないです。
担当さんは,「こちらのほうが先にやっているのに…後からを先越されて…」と,がっくし来ていたみたいです。
自分もさらに,その本の内容を見てガックシきました。ちなみに,その本の内容は,細かい所は違えど目指す方向性としては,自分が一番最初に出版社に企画を出してボツになった内容ほぼそのものでした。こっちの出版社だったら,すんなり受け入れられていたかもしれないな…って。
まぁ、いずれこういう先に越されることは目に見えていたので,筆不精な自分が悪い以外のなにものでもないんですけどもね。

ただ,今書いている内容はボツになったものとは方向性が違います。
…というか,出版社側のダメ出しだったり,他社に先越されるのとか想定して,じゃぁちょっと変えましょうと,企画段階で変更したんですよね。
だから,そのまま予定に沿って発売に向けて動いても問題はなかったのですが,何か悔しいなと思って…。
何かしら手は打たねばならないと思いました。
そこで,1章分を新たに追加執筆し,さらに開発に役立つと思われる付録もちょっとしょぼい内容ですが一応付けました。

具体的な内容については,もう少し言える状況になったらお伝えしたいと思います。
(内容を書いて競合他社にパクられて,差別化をするためにまた発売を延期するのは流石に勘弁したいので…)
皆さん,完成まで着々と進んでいます。もうしばらくお待ちください!!

明けましておめでとうございます。

新年あけましておめでとうございます!
本年度もどうぞProjectASURAをよろしくお願い致します。

今年は執筆していた本も発売されるので,本発売まで更新停止していたホームページもぼちぼち更新頻度を高められたらなぁと思っています。
色々とノウハウも徐々についてきたので,どこかで文章化出来たらいいなと思っています。

あとは昨年から息抜きとしてゲームっぽいものも作り始めました。こちらについてもある程度形になってきたらホームページの方で記事化出来たらと思ってます。

書籍進捗状況。

こんるる~、Pocolです。

皆さん、気になっている書籍進捗状況について。
手元に最初のレイアウト見本が届きました。


当然ながら完成していないのとネタバレ回避のため全部をお見せすることはできませんが…
コロナの影響もあり,ゆっくりですが順調に進んでおります。

詳細なご案内ができるまで,今しばらくお待ちくださいませ。

Dual Senseのサポート始めてみました。

おはようございます。Pocolです。

こちらで改造版RdpGamepadですが,DualSenseを試験的にサポートしてみることにしました。
Dual Sense Alpha Version

基本的には,開発しているlibDS4を差し替えただけなので,RdpGamepad側のロジック変更はありません。
人柱になってくれるかたがいらっしゃいましたら,不具合報告などをいただけると有難いです。
また,ViGEmBus側が,Dual Senseに対応していないので,転送先PCではDS4かXBoxコントローラーとしてしか振舞いませんので注意してください。

キューブマップからスフィアマップへの変換

おはござ!Pocolです。

前回の記事では,スフィアマップのサンプルについて紹介しましたが,今回はキューブマップをスフィアマップ形式で展開して表示する方法について紹介します。
キューブマップからの変換については,Paul Bourke氏の“Converting to/from cubemaps”にまとまった説明があります。
この記事に書かれているキューブマップから正距円筒方式への変換処理は次のような感じになります。

// スフィアマップ形式で表示するためのキューブマップ参照方向を求めます.
float3 ToCubeMapCoord(float2 texcoord)
{
    // [-1, 1]に変更.
    float2 uv = texcoord * float2(2.0f, -2.0f) - float2(1.0f, -1.0f);

    float theta = uv.x * F_PI;
    float phi   = uv.y * F_PI * 0.5f;

    float3 dir;
    dir.x = cos(phi) * cos(theta);
    dir.y = sin(phi);
    dir.z = cos(phi) * sin(theta);
    return dir;
}

この変換の使いどころですが,ImGuiでキューブマップを表示するのが面倒なので,上記の関数をかまして2Dマップとして表示するのに自分は使用しています。
…というわけで,展開して表示する方法について紹介しました。

スフィアマップのサンプリング

こんばんわ。Pocolです。
スフィアマップのサンプリングについて,忘れないようにメモしておこうと思います。
スフィアマップのサンプリング方法について,Jaume Sanchez Elias氏が“Creating a Spherical Reflection/Environment Mapping shader”という記事を書いています。

この記事では,反射ベクトルからテクスチャ座標を以下の式で算出できることが紹介されています。

\begin{eqnarray}
s = \frac{r_x}{2 \sqrt{{r_x}^2 + {r_y}^2 + (r_z + 1)^2}} + \frac{1}{2} \tag{1} \\
t = \frac{r_y}{2 \sqrt{{r_x}^2 + {r_y}^2 + (r_z + 1)^2}} + \frac{1}{2} \tag{2}
\end{eqnarray}

OpenGL 2.0の仕様書, “2.11.4 Generating Texture Coordinates”の項目に式の記載があるので,この式(1)と(2)は正しいものの様です(下図参照)。

興味深いのはこの記事のコメント欄にあるPierre Lepers氏のコメントです。
上記の式(1)と(2)はさらに単純化することができます。

まず式の分母部分を\(m\)と置きます。わかりやすいように\(r_x\)を\(x\)のように添え字部分で表現することにします。
\begin{eqnarray}
m &=& 2 \sqrt{x^2 + y^2 + (z + 1)^2} \\
&=& 2 \sqrt{x^2 + y^2 + z^2 + 2z + 1 } \\
\end{eqnarray}

ここで両辺の2乗をとります。
\begin{eqnarray}
m^2 = 4 (x^2 + y^2 + z^2 + 2z + 1) \tag{3}
\end{eqnarray}

ところで,\(x\), \(y\), \(z\)は反射方向を表す単位ベクトルの各成分であるので,

\begin{eqnarray}
\sqrt{x^2 + y^2 + z^2} = 1 \tag{4}
\end{eqnarray}

が成立します。
式(4)の両辺の2乗し,式(3)に代入します。

\begin{eqnarray}
m^2 &=& 4( 1 + 2z + 1) \\
&=& 4( 2 + 2z) \\
&=& 8( 1 + z) \tag{5}
\end{eqnarray}

式(5)について両辺に対して平方根を取ります。

\begin{eqnarray}
m &=& \sqrt{ 8 ( 1 + z) } \\
&=& \sqrt{8} \sqrt{1 + z} \tag{6}
\end{eqnarray}

\(\sqrt{8}\)は変数が無く定数扱いにできるので,事前に計算しておくことができます。
あとは,これをシェーダコードに落とせばよいです。コードに落とし込むと次のような感じになります。

// 方向ベクトルからスフィアマップのテクスチャ座標を求めます.
float2 ToSphereMapCoord(float3 dir)
{
    const float kSqrt8 = 2.82842712474619f;
    float s = 1.0f / (kSqrt8 * sqrt(1.0f + dir.z));
    return dir.xy * s + 0.5f;
}

dir.zは[-1, 1]なので,平方根内は[0, 2]の間で変化するのでマイナスになることは基本的にはありませんが,もしかしたらコンパイル警告とかは出るかもしれません。
…というわけで,スフィアマップのサンプリングについて紹介しました。