circle-loader
by
70/ 0

一、前書き

今年の初めにUE5はEpicによって公開されてから、注目を集めて話題になりつつあります。技術の面は、主に「大域照明技術」Lumenと「高精度モデルの詳細技術」Nanite2つの新機能をめぐって議論してきました。Naniteに関しては、詳しく紹介する記事がすでに[1] [2]に揃えました。本記事は、UE5のRenderDoc分析とソースコードから、いくつかの既存の技術資料を参考した上に、Naniteの直感的で概要的な理解を提供することが望ましいです。それに、ソースコードレベルの実装の詳細にあまり関与しなく、アルゴリズムの原理と設計のアイデアを明確にすることを目的としています。


 

二、次世代モデルのレンダリングには、何が必要か?

Naniteの技術上のポイントを分析するには、まず技術的要件という観点から分析する必要があります。この数十年、AAAタイトルは「インタラクティブな映画的なナラティヴ」と「オープンビッグワールド」に向かっています。迫真の映画のようなcutsceneを追求するため、細かいところまではっきり見えるようにキャラクターモデルを作らなければなりません。柔軟性に富んで豊富な世界を築くために、地図のサイズとオブジェクトの数量は指数的に増長させなければならなりません。この二点は、シーンの精度や複雑さへの要求を大幅に向上させました:シーンにおけるオブジェクトは多いだけでなく、かなり精細にもしなければなりません。

複雑なシーン作成のボトルネックは常に二つあります:

1.Draw CallによるCPU側の検証およびCPU-GPU間の通信コスト;

2.精度が足りないカリングによるOverdrawおよびそれによるCPU側のアセット計算の無駄

レンダリング技術の最適化も、近年ではこの2つの課題を中心に進められる傾向にあり、業界の技術的なコンセンサスも得られています。

CPU側の検証や状態の切替によるコストに対し、新しい世代のグラフィックAPI(Vulkan、DX12とMetal)があり、これはドライブのCPU側での検証作業の軽減に役に立ちます;違ったタスクを異なるQueueを通してGPU(Compute/Graphics/DMA Queue)に分配します;マルチコアCPUの優勢を利用して、GPUにコマンドを出します。これらの最適化に恵まれ、新世代のグラフィックAPIのDraw Call数量は前世代のグラフィックAPI(DX11、OpenGL)と比べて桁違いに増加しています[3]。

もう一つの最適化方向はCPUとGPU間のデータ通信を減少し、最終画面に寄与しない三角形をより正確にカリングします。この発想に基づき、GPU Driven Pipelineが誕生しました。GPU Driven Pipelineおよびカリングの内容について、[4]の記事を参考していただきたいと思います。

GPU Driven Pipelineはゲームで広く応用されて、モデルの頂点データをより細かい粒度のCluster(またはMeshlet)に分割し、毎Clusterの粒度をVertex Processing段階のCacheのサイズに適応し、Clusterを単位にして各種類のカリング(Frustum Culling、Occulsion CullingとBackface Culling)を行います。それは複雑的なシーンを最適化する最高の実践になって、この新しい頂点処理のプロセスはGPUメーカーにも認められます。

しかし、従来のGPU Driven PipelineはCompute Shaderを頼ってかリングします。かリング後のデータはGPU Bufferに格納して、Execute Indirect類のAPIを経て、かリングした後のVertex/Index BufferをGPUのGraphics Pipelineに再び置いていきます。知らず知らずのうちに読み取り書き込みのコストが加わりました。また、頂点のデータは重複的に読み取られます(Compute Shaderはかリング前に読み取ります。さらにGraphics Pipelineは作成中にVertex Attribute Fetchを通して読み取ります)。

以上の原因で、さらに頂点処理の柔軟性を向上させるために、NVidiaは最初にMesh Shader[5]という概念を導入しました。これは、従来の頂点処理の段階で固定されたユニット(VAF、PDタイプのハードウェアユニット)の一部を徐々に取り除き、さらにこのようなことを開発者に委ねて、プログラム可能なパイプライン(Task Shader/Mesh Shader)を通じて処理できることを望みます。

Clusterの見取図

従来のGPU Driven PipelineはCompute Shaderを頼ってかリングし、かリング後のデータはVRAMを経て、頂点処理パイプラインに転送する。

 

Mesh ShaderのPipelineに基づき、Clusterかリングは頂点処理段階の一部となり、不要なVertex Buffer Load/Storeを減らす。


三、これらで十分なのか?

ここまで、モデル数、三角形頂点数とメッシュ数の問題はかなり最適化と改善されました。高精度のモデル、ピクセルレベルの小さな三角形はレンダリングパイプラインに新しいストレスをもたらしました:ラスタライズとオーバードローOverdrawのストレスです。

一体ソフトラスタライズがハードラスタライズに勝つ可能性はあるか?

この問題を究明するには、まずハードウェアラスタライズはいったい何をしたのか、一般的な応用シーンはどうなのかを理解しなければなりません。(興味がある方は [6] の記事を参考します。)簡単に言えば:従来のラうたらイズハードウェアが設計されたのは、入力した三角形のサイズは1ピクセルよりずっと大きいと予想されました。このような発想に従え,ハードウェアラスタライズのプロセスには通常階層的です。

 

Nカードのラスターを例として、一つの三角形は二つの段階のラスタライズを経ます:Coarse RasterFine Raster。前者は一つの三角形を入力にして、8×8ピクセルをクワッドにして、三角形を若干のクワッドをラスタライズします。(元サイズはが1/8*1/8 のFrameBufferは粗いラスタライズされたと理解してもよい)。

 

この段階で、低解像度のZ-Bufferを介して、遮られたクワッドは丸ごとにかリングされ、NカードでZ Cullと呼ばれます;Coarse Raster後、Z Cullを通ったクワッドは次の段階に転送してFine Raster処理します。最後に、シェーディング計算用のピクセルを生成します。Fine Rasterの段階に、熟知しているEarly Zがあります。Mip-Mapサンプリング計算のため、各ピクセルの隣のピクセルの情報を知らなければなりません。サンプリングUVの差分を利用して、Mip-Mapサンプリング階層の計算根拠とします。そのため、Fine Rasterは最終的に一つ一つのピクセルをエクスポートするではなく、2×2の小さなピクセルクワッドをエクスポートしますPixel Quad

ピクセルサイズの三角形に対し、ハードウェアラスタライズの無駄は明らかです。まず、これらの三角形は通常8×8より小さいため、Coarse Rasterの段階はほとんど役に立たないのです。さらに長くて狭い三角形に対しては、複数のクワッドにまたがることが多いので、Coarse Rasterのかリングは無効になるだけでなく、余計な計算負荷が増えました;また、大きな三角形に対し、Pixel QuadベースのFine Raster段階に、三角形のエッジで少数の無駄なピクセルを生じます。三角形の総面積のごく一部に過ぎないが;小さな三角形にとって、最悪の場合Pixel Quadが三角形面積の4倍のピクセルを生成し、これがPixel Shaderの実行段階に含まれているため、WARPにおける有効なピクセルは大幅に減少されました。


小さな三角形はPixel Quadによってラスタライズの無駄が引き起こされた

上記の理由で、ピクセルレベルの小さな三角形という前提の下で、ソフトラスタライズ(Compute Shaderベース)は確かにハードラスタライズに勝つ可能性が存在しています。これはまさにNaniteのコア最適化の一つとして、UE5による小さな三角形へのラスタライズの効率を3倍[7]ほど向上させた。

Deferred Material

長い間、オーバードローの問題はグラフィックスレンダリングの性能ボトルネックであり、この課題をめぐって最適化の話題が次々と現れてきます。モバイル端末に、おなじみなTile Based Renderingアーキテクチャがあり[8];レンダリングパイプラインの進化ポロセスには、Z-PrepassDeferred RenderingTile Based RenderingおよびClustered Renderingを提案する人もいます。実際、これらの違ったレンダリングパイプラインの枠組みは同じ問題を解決しようと思われます:光源が一定の数を超えたら、マテリアルが複雑になったら、如何にShaderにおける大量な下位レンダリングロジックを避けるのか?無駄なオーバードローを減らすか?これについてもっと知りたい方は、[9]の記事を読んでいただきたい。

通常,レンダリングパイプラインを遅延するには、G-Bufferと呼べれるRender Targetが必要となり、これらのスタンプには、すべてのライティングの計算が必要なマテリアル情報が格納されています。現在のAAAタイトルでは、マテリアルの種類は複雑で変化に富んでいて、格納必要G-Buffer情報も年々増加しています。2009年のゲーム《Kill Zone 2》を例にして、G-Bufferの全体的なレイアウトは以下の通り:

Lighting Buffer以外に、G-Bufferの必要なスタンプ数は4枚で、合計16 Bytes/Pixelになりますが; 2016年になると、ゲーム《Uncharted 4》のG-Bufferレイアウトは以下の通りになります:

G-Bufferのスタンプ数は8枚で、つまり32 Bytes/Pixelとなる。つまり、同じピクセルの場合、マテリアルのの複雑さや迫真さの向上に伴って、年に改善された解像度を考慮に入れなくても、G-Bufferの必要な帯域幅が2倍となりました。

Overdrawが高いシーンに対し、G-Bufferの描画によって生じた読み書き帯域幅は性能のボトルネックになりがちで、そのゆえ、学界によりVisibility Bufferと呼ばれる新しいレンダリングパイプラインが誕生しました [10][11]。Visibility Bufferベースのアルゴリズムは単独で肥大なG-Bufferを生成しなくなって、代わりに帯域幅のコストがもっと低いVisibility Bufferを使用し始めました。Visibility Bufferには以下のような情報が必要となります:

(1)Instance ID、現在のピクセルはどのInstanceに属するか(16~24 bits);
(2)Primitive ID、現在のピクセルはInstanceのどの三角形に属するか(8~16 bits);
(3)Barycentric Coord、現在のピクセルは三角形内に位置する座標、重心座標で表します(16 bits);
(4)Depth Buffer、現在のピクセルの深度(16~24 bits);
(5)Material ID、現在のピクセルはどのマテリアルに属するか(8~16 bits);

以上のように、およそ8~12 Bytes/Pixelを格納することでシーンにあるすべてのポリゴンのマテリアル情報を表することができます。同時に、大域の頂点数データとマテリアルスタンプの表をメンテナンスする必要があります。中には現在フレームにおける全てのポリゴンの頂点データ、マテリアルパラメータおよびスタンプが格納されています。

ライトシェーディング段階では、Instance ID和Primitive IDに従って、グローバルのVertex Bufferからの関連する三角形情報にインデックスを付けるだけで済みます。さらに、ピクセルの重心座標に従って、Vertex Buffer内の頂点情報( UV、Tangent Spaceなど)補間を実行してピクセルごとの情報を取得します。さらに、Material IDに従って、関連するマテリアル情報にインデックスを付け、スタンプサンプリングなどの操作を実行し、それをライティングの計算のところに入力してシェーディングを完了させます。このタイプの方法は、Deferred Texturingとも呼ばれています。

 

以下は、G-Bufferに基づくレンダリングパイプラインのプロセスです。

以下は、Visibility-Bufferに基づくレンダリングパイプラインのプロセスです。

直感的に、Visibility Bufferは、シェーディングに必要な情報のストレージ帯域幅を削減します(G-Buffer-> Visibility Buffer);また、ライティング計算に関連する幾何学的情報とスタンプ情報の読み取りをシェーディングステージまで遅らせるため、スクリーンに不可視なピクセルはこれらのデータを読み取る必要がなくなり、頂点位置を読み取るだけで済みます。この2つの理由により、Visibility Bufferの帯域幅のオーバーヘッドは、高解像度の複雑なシーンで従来のG-Bufferと比較して大幅に削減されます。ただし、グローバルのジオメトリとマテリアルデータを同時に維持すると、エンジンデザインが複雑になり、マテリアルシステムの柔軟性が低下します。また、Bindless Texture[12]などの完全なハードウェアプラットフォームでまだサポートされていないGraphicsAPIを使用する場合、互換性を助長しません。


 

四、Naniteでの実装

ローマは一日にして成らず。成熟した学術および工学分野によって生み出された技術革新には、前任者の思考と実践が必要です。そのため、関連する技術的背景を紹介しました。Naniteは、以前のソリューションを要約し、現在のハードウェアの計算能力を組み合わせ、次世代のゲームテクノロジーのニーズから出発し、優れたエンジニアリング実践の集大成と言えます。

 

その中心的な考え方は、頂点処理の最適化とピクセル処理の最適化に分けられます。頂点処理の最適化は、主にGPU Driven Pipelineです。ピクセル処理の最適化は、Visibility Bufferに基づき、ソフトラスタライズと組み合わせて完成されたのです。UE5 Ancient Valley技術のデモにあるRenderDocのフレームキャプチャーと関連ソースコードを借りて 、Nanite技術の真正面を垣間見ることができます。次の図はアルゴリズムを示すフォローチャートです。

Instance Cull && Persistent Cull

GPU Driven Pipelinの発展過程を踏まえ、Naniteの実装も理解しがたくなくなります:各Nanite Meshは前処理段階で複数のClusterに分割され、各Clusterには128個の三角形が含まれ、Mesh全体がBVH(Bounding Volume Hierarchy)という形でツリー構造に編成され、各リーフノードは一つのClusterを表します。カリングには、錐台カリングとHZBに基づくオクルージョンカリング2つのステップがあります。その中で、Instance CullはMeshをユニットにして、Instance Cullを抜くMeshは、BVHのルートノードをPersistent Cull段階に転送し、階層的かリングを行います(BVHノードがかリングされた場合、その子ノードは処理されません)。

 

では、Persistent Cull段階のカリングタスクの数をCompute Shaderのスレッドの数にマッピングするにはどうすればよいですか?最も簡単な方法は、各BVHツリーに個別のスレッドを与えることです。つまり、1つのスレッドがNanite Meshを担当します。ただし、各Meshの複雑さが異なるため、BVHツリーのノード数や深度が大きく異なっています。そのゆえ、各スレッドのタスク処理時間が異なり、スレッドは互いに待機し、最終的には並列処理が悪くなります。では、処理が必要な各BVHノードに個別のスレッドを割り当てることができますか?これはもちろん最も理想的な状況ですが、実際には、かリングの全体が階層的で動的ですので、かリング前にいくつのBVHノードは処理されるかは事前に知ることはできません。

 

Naniteの解決法は、固定数のスレッドを設定して、各スレッドはグローバルFIFOタスクキューを介してBVHノードをフェッチしてかリングします。もしそのノードがかリングに通過すると、このノードに属するすべての子ノードもタスクキューの最後に置きます。そして、キュー全体が空になり、新しいノードが生成されなくなるまで、グローバルキューから新しいノードをフェッチします。これは実際にマルチスレッド 併発の生産-消費者モデルです。違ったところは、ここの各スレッドがプロデューサーとコンシューマーの両方として機能することです。このモデルを通じて、Naniteは、各スレッド間の処理時間がほぼ同じになるようにします。

かリングは二つのPassに分けられます:Main PassPost Pass(コンソール変数を介してMain Passのみに設定できます)。この2つのPassのロジックは基本的に同じです。違いは、Main Passのオクルージョンカリングに使用されるHZBが前のフレームデータに基づいて作成されるのに対し、Post PassはMain Passの終了後に作成された現在のフレームのHZBを使用することです。これは、前のフレームのHZBが可視Meshを誤って削除するのを防ぐためです。

NaniteはMesh Shaderを使用しないことに注意してください。その理由として、一方ではMesh Shaderのサポートがまだ普及していないためですが、他方では、Naniteはソフトラスタライズを使用しているため、Mesh Shaderのエクスポートは依然としてGPUバッファーに書き戻されてからソフトラスタライズの入力に用いられます。したがって、CSソリューションと比べ、帯域幅の節約はあまりありません。

Rasterization

かリングが終わった時、各Clusterはスクリーンにおける空間のサイズによって違ったラスタライザーに送る。大きいな三角形や非Nanite Meshなら、相変わらずハードラスタライズに基づき,小さな三角形なら、Compute Shaderによって書き込んだソフトラスタライズに基づきますNaniteのVisibility BufferはR32G32_UINTのスタンプであり(8 Bytes/Pixel)、中には、Rチャネルの0~6 bitはTriangle IDを格納し、7~31 bitはCluster IDを格納し、Gチャンネルは32 bitの深度を格納します。


Cluster ID


Triangle ID


Depth

ソフトラスタライズの論理はより単純:スキャンラインアルゴリズムに基づき、各Clusterは個別なCompute Shaderを起動し、Compute Shaderの初期階段で全てのClip Space Vertex Positonを計算してShared Memoryにキャッシュします。その後、CSの各スレッドは対応する三角形のIndex Bufferと変換後のVertex Positionを読み取ります。Vertex Positionに基づき、三角形の辺を計算し、裏側と小さな三角形(1ピクセル未満)のかリングを実行してから、アトミック操作を利用してZ-Testを完成します。それにデータをVisibility Bufferに書き込みます。ちなみに、ソフトラスタライズロジック全体が簡潔で効率的であることを保証するために、Nanite Meshは、スケルタルアニメーション、頂点変換、またはマスクを含むマテリアルをサポートしていません。

 

Emit Targets

データ構造を可能な限りコンパクトにし、読み取りと書き込みの帯域幅を減らすために、ソフトラスタライズに必要なすべてのデータはVisibility Bufferに格納されますが、シーン内のハードウェアラスタライズによって生成されたピクセルと混合するために、到底Visibility Bufferの余分情報を一致しているVisibility BufferおよびMotion Vector Bufferに書き込む必要があります。この段階は通常、いくつかのフルスクリーンパスで構成されています:

(1)Emit Scene Depth/Stencil/Nanite Mask/Velocity Buffer

このステップは最終シーンに必要なRenderTargetデータに従って、最大4つのBufferをエクスポートします。このうち、Nanite Maskは0/1で現在のピクセルが通常のメッシュであるかNanite Meshであるか(Visibility Bufferに対応するCluster IDによって得られる)を示す。Nanite Mesh Pixelの場合、Visibility BufferのDepthをUINTからfloatに変換し、Scene Depth Bufferに書き込みます。そして、Nanite Meshがデカールを受け入れるかどうかに応じて、デカールに対応するStencil ValueをScene Stencil Bufferに書き込み、前のフレームの位置に従って現在のピクセルのMotion Vectorを計算し、それをVelocity Bufferに書き込みます。非Nanite Meshはそうではなく、直接Discardしてスキップします。


Nanite Mask


Velocity Buffer


Scene Depth/Stencil Buffer

(2)Emit Material Depth

このステップはMaterial ID Bufferを生成します。少し違ったのは、UINTタイプのスタンプに格納されず、UINTタイプのMaterial IDをfloatに変換してD32S8形式のDepth/Stencil Targetに保存します(理由は下文に詳しく説明する)、理論上は最大2^32種類のマテリアルをサポートします(実際に14 bitsだけはMaterial IDの格納に用いられる)。Nanite Maskは Stencil Bufferに書き込まれます。


Material Depth Buffer

Classify Materials && Emit G-Buffer

Visibility Bufferの原理は詳しく紹介してきました。シェーディング計算段階での実装の1つは、グローバルマテリアルテーブルを維持することです。このテーブルには、マテリアルパラメータと関連するスタンプのインデックスが格納されています。各ピクセルのMaterial IDに従って対応するマテリアルが検索してマテリアルを解析します。Virtual TextureやBindless Texture/Texture Arrayなどの技術ソリューションを利用して対応するスタンプ情報を取得します。これは単純なマテリアルシステムで実現可能ですが、UEには非常に複雑なマテリアルシステムが含まれ、各マテリアルには異なるShading Modelがあり、同じShading Modelの各マテリアルパラメータは、マテリアルエディタで複雑な接続計算もできます。マテリアルShader Codeを動的に生成するモードは、明らかに上記のスキームでは実現できません。

各種類のマテリアルのShader Codeをマテリアルエディタに基づいて動的に生成できるようにするには、各マテリアルのPS Shaderを少なくとも1回実行する必要がありますが、画面スペースのマテリアルID情報しかありません。過去にオブジェクトを1つずつ描画する同時に、対応するマテリアルShader(Object Space)を実行するのとは異なります。NaniteのマテリアルShaderはScreen Spaceで実行されます。これによって可視性の計算とマテリアルパラメータの計算を分離させます。これもDeferred Materialという名前の由来です。しかし、これは新たなパフォーマンスの問題を引き起こしました。シーンには何千万のマテリアルがあり、各マテリアルはフルスクリーンパスで描画されます。オーバードローによって引き起こされる帯域幅のストレスは非常に高くなります。無意味なオーバードローを減らす方法は、新しい挑戦になります。

そのため、NaniteはBase Passの描画段階では、各マテリアルにフルスクリーンパスを与えることではなく、いくつかの8×8のタイルに分割してから描画します。たとえば、画面サイズが800×600の場合、各マテリアルを描画する時、それぞれが100×75個のタイルを生成します。各タイルがスクリーンの位置に対応します。タイルを丸ごとにかリングできるように、Emit Targetsの後、NaniteはCSを起動して、各タイルに含まれるMaterial IDのタイプをカウントします。Material IDに対応するDepth値は事前に並べ替えられているため、このCSは各8×8タイルのMaterial Depthの最大値と最小値をカウントし、Material ID RangeとしてR32G32_UINTのスタンプに保存します。

Material ID Range

この画像では、各マテリアルは、VS段階での自身のタイルの位置に従って、スタンプの対応する位置のMaterial ID Rangeをサンプリングします。現在のマテリアルのMaterial IDがRange内にある場合、マテリアルのPSは引き続き実行します。それ以外の場合は、現在のタイルのピクセルがマテリアルを使用しておらず、タイル全体をかリングすることができます。この時点では、VSの頂点位置をNaNに設定し、GPUは対応する三角形をかリングします。通常、一つのタイル内のマテリアルの種類はそれほど多くないため、この方法では不要なOverdrawを効果的に減らすことができます。

 

実際、タイル分類によってマテリアルの分岐を減らし、レンダリングロジックを簡素化するというアイデアが提案されるのは初めてではありません。『Uncharted 4』が遅延ライティングを実装している時[13]、マテリアルには多種のShading Modelが含まれるため、各Shading Modelは個別なフルスクリーンCSを起動することを避けるために、画面をタイル(16×16)に分割し、且つタイル内のShading Modelの種類をカウントします。タイル内のShading Model のRangeによって、タイルごとに個別なCSを起動し、Range内に対応するLighting Shaderを取ります。この方法で複数のフルスクリーンパスまたは多くの分岐ロジックを含むUber Uber Shaderが避けられ、遅延ライティングのパフォーマンスが大幅に向上されます。


Uncharted 4の中にタイルベースにShading Model Rangeを統計する

タイルベースのかリングが終わった後、Material Depth Bufferは役に立つようになります。Base Pass PSの段階に、Material Depth BufferはDepth/Stencil Targetに設定され、同時にDepth/Stencil Test開かれてCompare FunctionはEqualに設定されます。現在ピクセルのMaterial IDは描画予定の材質IDは同じく場合(Depth Test Pass)、且つ当該ピクセルはNanite Mesh(Stencil Test Pass)である時、本格的にPSを実行します。それでハードウェアのEarly Z/Stencilを借りて、逐ピクセルの材質IDのかリングを完成します。描画とかリングの原理は以下のように示しています。

赤色はかリングされた区域である

全Base Passは二つの部分によって構成されます。まず非Nanite MeshのG-Bufferを描画します。この部分はUE4のロジックと同様に、依然としてObject Spaceの中に実行します;その後上の手順に沿ってNanite MeshのG-Bufferを描画します。その中にマテリアルに必要な付加的なVS情報(UV,Normal,Vertex Colorなど)は、ピクセルのCluster IDとTriangle IDを介して相応するVertex Positionにインデックスして、さらにClip Spaceに変換します。そして、Clip Space Vertex Positionと現在ピクセルの深度値に従って、現在ピクセルの重心座標およびClip Space Positionの勾配(DDX/DDY)を求めます。最後、重視座標と勾配度を各種類のVertex Attributesに代入して補間すると、全てのVertex Attributesおよび勾配が得られます。(勾配はサンプリングのMip Mapレベルの計算に用いられる)。

以上、Naniteの技術的背景と完全な実装ロジックを分析しました。

参考資料:

[1] 《A Macro View of Nanite》

[2] 《UE5 Nanite实现浅析》

[3] 《Vulkan API Overhead Test Added to 3DMark》

[4] 《剔除:从软件到硬件》

[5] 《Mesh Shading: Towards Greater Efficiency of Geometry Processing》

[6] 《A Trip Through the Graphics Pipeline》

[7] 《Nanite | Inside Unreal》

[8] 《Tile-Based Rendering》

[9] 《游戏引擎中的渲染管线》

[10] 《The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading》

[11] 《Triangle Visibility Buffer》

[12] 《Bindless Texture》

[13] 《Deferred Lighting in Uncharted 4》


UWA公式サイト:https://jp.uwa4d.com

UWA公式ブログ:https://blog.jp.uwa4d.com

UWA公式Q&Aコミュニティ(中国語注意):https://answer.uwa4d.com