Vulkanの話 第7回

オフスクリーンバッファへのレンダリング 16/09/22 up

UE4ぷちコン作成してたら1ヶ月以上間が空きました。

Monshoです。コニチハコニチハ。

今回はオフスクリーンバッファへレンダリングし、これを利用してバックバッファへレンダリングする手法を実装してみました。

Deferred Renderingを実装する上では絶対に欠かせない機能ではありますが、そもそも今時の描画エンジンで使えなかったら困る機能なので最初にやっておこうかと。

なお、今回からサンプル用のライブラリを整備しながら作成していきます。

インターフェースや実装は都度変わっていくことになるかと思いますので、記事の内容とソースコードが一致しなくなる可能性があります。

ご容赦ください。

コードが変わってもVulkanAPIの叩き方は変わらないはずなので大丈夫だと思いますが。

ソースコードはGitHubにすでにコミット済みですので、こちらを参照してください。

GitHub

今回のサンプルは Sample004 です。

まずはシェーダから見ていきましょう。

オフスクリーンへの描画は前回と同じなので割愛し、オフスクリーン→バックバッファにレンダリングするシェーダを見ていきましょう。

data フォルダ内の post.vert, post.frag がシェーダコードとなります。

post.vert

#version 450#extension GL_ARB_separate_shader_objects : enable#extension GL_ARB_shading_language_420pack : enable layout (location = 0) out vec2 outUV; out gl_PerVertex { vec4 gl_Position;};void main(){ float x = (gl_VertexIndex & 0x1) == 0 ? 0.0 : 1.0; float y = (gl_VertexIndex & 0x2) == 0 ? 0.0 : 1.0; outUV = vec2(x, 0.0 - y); gl_Position = vec4(vec2(x, y) * 2.0 - 1.0, 0.0, 1.0);}

post.frag

#version 450#extension GL_ARB_separate_shader_objects : enable#extension GL_ARB_shading_language_420pack : enable layout (location = 0) in vec2 inUV; layout (location = 0) out vec4 outFragColor; layout (binding = 1) uniform sampler2D samColor; layout (binding = 2) uniform sampler2D samDepth;void main() { vec4 color = texture(samColor, inUV, 0); float depth = texture(samDepth, inUV, 0).r; outFragColor = (depth < 0.999) ? vec4(1.0 - color.rgb, color.a) : vec4(0, 0, 0, 0);}

頂点シェーダを見てください。

こちらは頂点入力が存在せず、頂点インデックスからスクリーン座標とUV値を求めるようにしています。

画面いっぱいのQuadを描画する場合の定番処理ですが、私の記憶が確かなら OpenGL における頂点インデックス番号は gl_VertexID だったかと思います。

Vulkan (と言うか、SPIR-V?) では、gl_VertexIndex を用いるようです。

ピクセルシェーダの方は2枚のテクスチャを利用しています。

1つはカラーバッファで、こちらはオフスクリーンに描画した結果です。

もう1つの binding = 2 のテクスチャは深度バッファです。

今回深度バッファはステンシルも込みですが、このテクスチャではステンシルの値は取得できません。

これはDirectXと同じで、バッファとしては1つのバッファとして用いるけれど、テクスチャとして利用する場合は別々に分ける必要があるわけです。

シェーダを見ていただいて何をやりたいのかはわかっていただけると思います。

次にオフスクリーンバッファを作成しましょう。

これは深度バッファと同様にImageを作成、デバイスメモリを確保、バインド、Viewを作成という流れを取ります。

深度バッファとは設定するフラグ類が変化しますが、基本的な部分は変わりません。

// 指定のフォーマットがサポートされているか調べる vk::FormatProperties formatProps = owner.GetPhysicalDevice().getFormatProperties(format); assert(formatProps.optimalTilingFeatures & vk::FormatFeatureFlagBits::eColorAttachment); vk::ImageAspectFlags aspect = vk::ImageAspectFlagBits::eColor; vk::Device& device = owner.GetDevice();// イメージを作成する vk::ImageCreateInfo imageCreateInfo; imageCreateInfo.imageType = vk::ImageType::e2D; imageCreateInfo.extent = vk::Extent3D(width, height, 1); imageCreateInfo.format = format; imageCreateInfo.mipLevels = mipLevels; imageCreateInfo.arrayLayers = arrayLayers; imageCreateInfo.usage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eTransferDst | vk::ImageUsageFlagBits::eSampled; image_ = device.createImage(imageCreateInfo);if (!image_){ return false;}// メモリを確保 vk::MemoryRequirements memReqs = device.getImageMemoryRequirements(image_); vk::MemoryAllocateInfo memAlloc; memAlloc.allocationSize = memReqs.size; memAlloc.memoryTypeIndex = owner.GetMemoryTypeIndex(memReqs.memoryTypeBits, vk::MemoryPropertyFlagBits::eDeviceLocal); devMem_ = device.allocateMemory(memAlloc);if (!devMem_){ return false;} device.bindImageMemory(image_, devMem_, 0);// Viewを作成 vk::ImageViewCreateInfo viewCreateInfo; viewCreateInfo.viewType = vk::ImageViewType::e2D; viewCreateInfo.format = format; viewCreateInfo.components = { vk::ComponentSwizzle::eR, vk::ComponentSwizzle::eG, vk::ComponentSwizzle::eB, vk::ComponentSwizzle::eA }; viewCreateInfo.subresourceRange.aspectMask = aspect; viewCreateInfo.subresourceRange.levelCount = 1; viewCreateInfo.subresourceRange.layerCount = 1; viewCreateInfo.image = image_; view_ = device.createImageView(viewCreateInfo);if (!view_){ return false;}// レイアウト変更のコマンドを発行する{ vk::CommandBufferBeginInfo cmdBufferBeginInfo; vk::BufferCopy copyRegion; vk::ImageSubresourceRange subresourceRange; subresourceRange.aspectMask = aspect; subresourceRange.levelCount = 1; subresourceRange.layerCount = 1; Image::SetImageLayout( cmdBuff, image_, vk::ImageLayout::eUndefined, vk::ImageLayout::eColorAttachmentOptimal, subresourceRange); currentLayout_ = vk::ImageLayout::eColorAttachmentOptimal;}

指定されたフォーマットがサポートされているかチェックします。

次に Image を作成しますが、usage フラグには vk::ImageUsageFlagBits の eColorAttachment, eTransferDst, eSampled を指定します。

それぞれ、レンダリングターゲットのカラーターゲットとして利用する、転送先として利用する(バッファクリアのため)、サンプリング可能とする(シェーダでサンプリングするため)という意味になります。

Image を作成したら必要なメモリを確保し、これと Image インスタンスをバインドして関連付けます。

その後に View を作成しますが、特に目新しいことはしていません。

最後に Image のレイアウトをレンダリングターゲットのカラーターゲットとして利用するためのレイアウトに変更しています。

次は深度バッファです。

シェーダの項目で解説しましたが、今回使用している深度バッファはD32S8フォーマットのステンシル付き深度バッファです。

このような深度バッファをシェーダリソースとして使用する場合、デプスターゲットとして利用したViewはそのまま使うことが出来ません。

そこで、深度のみを利用するViewと、ステンシルのみを利用するViewの2つを作成します。

// Viewを作成 vk::ImageViewCreateInfo viewCreateInfo; viewCreateInfo.viewType = vk::ImageViewType::e2D; viewCreateInfo.format = format; viewCreateInfo.components = { vk::ComponentSwizzle::eR, vk::ComponentSwizzle::eG, vk::ComponentSwizzle::eB, vk::ComponentSwizzle::eA }; viewCreateInfo.subresourceRange.aspectMask = aspect; viewCreateInfo.subresourceRange.levelCount = 1; viewCreateInfo.subresourceRange.layerCount = 1; viewCreateInfo.image = image_; view_ = device.createImageView(viewCreateInfo);if (!view_){ return false;}// テクスチャとして使用する際のViewを作成if (aspect == vk::ImageAspectFlagBits::eDepth){ depthView_ = view_;}else if (aspect == vk::ImageAspectFlagBits::eStencil){ stencilView_ = view_;}else{ viewCreateInfo.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eDepth; depthView_ = device.createImageView(viewCreateInfo); viewCreateInfo.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eStencil; stencilView_ = device.createImageView(viewCreateInfo); if (!depthView_ || !stencilView_) { return false; }}

デプスターゲット用のViewを作成後、このバッファのフォーマットが深度のみ、もしくはステンシルのみであればViewはそのまま利用できます。

そうでない場合、つまり今回のような深度とステンシルを使う場合はそれぞれのViewを作成します。

と言っても、aspectMask を eDepth, eStencil 単体にするだけです。

Framebuffer オブジェクトの作成は割愛します。

main.cpp の 97行目からが対応するコードですが、Swapchain から作成する FBO は深度バッファを利用しません。

オフスクリーンのバッファの方に深度バッファを指定します。

さて、今回は描画パスが2パスになります。

メッシュ(というよりただの板)を描画する MeshPass と、ここで描画された結果をバックバッファに描画する PostPass です。

DrawCallは計3回ですが、内2回は前回と同様なので、同一パス、同一リソースでモデル行列だけ PushConstant を使って設定しているだけです。

この2つの描画パスのため、いくつかのオブジェクトは2つ作成する必要が出てきます。

まずは RenderPass オブジェクトです。

このオブジェクトは生成時に、使用するレンダリングターゲットの枚数とそれぞれのフォーマットを指定する必要があります。

他にも、描画パス終了時のイメージレイアウトを変更するかどうかや、Subpassの数、Subpass間でのイメージレイアウトの変更方法を提供しています。

今回は描画終了時のレイアウト変更も、Subpass の複数利用も行っていないので、重要なのはレンダリングターゲットの枚数とフォーマットです。

main.cpp の該当コードは341行目からです。

bool InitializeMeshPass(vsl::Device& device){ vk::Format colorFormat = vk::Format::eB10G11R11UfloatPack32; vk::Format depthFormat = vk::Format::eD32SfloatS8Uint; return meshPass_.InitializeAsColorStandard(device, colorFormat, depthFormat);}bool InitializePostPass(vsl::Device& device){ vk::Format colorFormat = device.GetSwapchain().GetFormat(); return postPass_.InitializeAsColorStandard(device, colorFormat, nullptr);}

MeshPass はオフスクリーンバッファ1枚と深度バッファ1枚です。フォーマットもそれぞれに対応させてあります。

PostPass はSwapchain のバッファのみフォーマットを設定しています。

この描画パスでは深度バッファが不要でので、深度バッファフォーマットは nullptr を設定しています。

次に作成するオブジェクトは DescriptorSet オブジェクトです。

このオブジェクトは描画パス分必要なのではなく、通常はDrawCallの数分だけ必要になります。

DrawCall時に各シェーダステージにどのリソースをいくつ使うか、何をバインドするかを事前に設定しておくのがこのオブジェクトです。

そしてまた面倒なことに、このオブジェクトを生成する際には別の2つのオブジェクトが必要になります。

DescriptorPool オブジェクトは設定するリソースの枠 (これをデスクリプタと呼ぶ) を予め確保しておく、いわゆるオブジェクトプールです。

DescriptorSetLayout オブジェクトは DescriptorSet がどのようなデスクリプタを何個、どのステージで使用するかのレイアウト情報を保存したオブジェクトです。

このオブジェクトはあくまでもレイアウト情報を持っているだけで、実際に使用するリソースの情報は持ちません。

もうちょっと詳しい説明は 第4回 でも行いましたが、DirectX12とはちょっと設計が違いますね。

DirectX12の DescriptorTable などの説明は こちら をご覧ください。

DX12では DescriptorHeap にリソースのViewが存在しており、DescriptorTable は Heap の何番から何個を使う、という指定を行います。

RootSignature は Table を複数持ち、Table が参照する Heap を RootSignature 側で切り替えることが可能でした。

Vulkan の DescriptorSet はリソースのViewを自身が保持します。

保持するための枠を Pool から取得し、そのレイアウト設定を SetLayout から取得する感じです。

実際のコードは以下です。

この辺はまだライブラリ化してません。

// デスクリプタプールを作成する{ std::array<vk::DescriptorPoolSize, 2> typeCounts; typeCounts[0].type = vk::DescriptorType::eUniformBuffer; typeCounts[0].descriptorCount = 2; typeCounts[1].type = vk::DescriptorType::eCombinedImageSampler; typeCounts[1].descriptorCount = 3; // デスクリプタプールを生成 vk::DescriptorPoolCreateInfo descriptorPoolInfo; descriptorPoolInfo.poolSizeCount = static_cast<uint32_t>(typeCounts.size()); descriptorPoolInfo.pPoolSizes = typeCounts.data(); descriptorPoolInfo.maxSets = 2; descPool_ = device.GetDevice().createDescriptorPool(descriptorPoolInfo);}// デスクリプタセットレイアウトを作成する// 略{ // 描画時のシェーダセットに対するデスクリプタセットのレイアウトを指定する std::array<vk::DescriptorSetLayoutBinding, 2> layoutBindings; // CombinedImageSampler (Color buffer) for PixelShader layoutBindings[0].descriptorType = vk::DescriptorType::eCombinedImageSampler; layoutBindings[0].descriptorCount = 1; layoutBindings[0].binding = 1; layoutBindings[0].stageFlags = vk::ShaderStageFlagBits::eFragment; layoutBindings[0].pImmutableSamplers = nullptr; // CombinedImageSampler (Depth buffer) for PixelShader layoutBindings[1].descriptorType = vk::DescriptorType::eCombinedImageSampler; layoutBindings[1].descriptorCount = 1; layoutBindings[1].binding = 2; layoutBindings[1].stageFlags = vk::ShaderStageFlagBits::eFragment; layoutBindings[1].pImmutableSamplers = nullptr; // レイアウトを生成 vk::DescriptorSetLayoutCreateInfo descriptorLayout; descriptorLayout.bindingCount = static_cast<uint32_t>(layoutBindings.size()); descriptorLayout.pBindings = layoutBindings.data(); descLayouts_.push_back(device.GetDevice().createDescriptorSetLayout(descriptorLayout, nullptr));}// デスクリプタセットを作成する{ vk::DescriptorImageInfo texDescInfo( sampler_, texture_.GetView(), vk::ImageLayout::eGeneral); vk::DescriptorImageInfo postDescInfo( sampler_, offscreenBuffer_.GetView(), vk::ImageLayout::eGeneral); vk::DescriptorImageInfo postDepthDescInfo( sampler_, depthBuffer_.GetDepthView(), vk::ImageLayout::eGeneral); vk::DescriptorBufferInfo dbInfo = sceneBuffer_.GetDescInfo(); // デスクリプタセットは作成済みのデスクリプタプールから確保する vk::DescriptorSetAllocateInfo allocInfo; allocInfo.descriptorPool = descPool_; allocInfo.descriptorSetCount = static_cast<uint32_t>(descLayouts_.size()); allocInfo.pSetLayouts = descLayouts_.data(); descSets_ = device.GetDevice().allocateDescriptorSets(allocInfo); // デスクリプタセットの情報を更新する std::array<vk::WriteDescriptorSet, 4> descSetInfos{ vk::WriteDescriptorSet(descSets_[0], 0, 0, 1, vk::DescriptorType::eUniformBuffer, nullptr, &dbInfo, nullptr), vk::WriteDescriptorSet(descSets_[0], 1, 0, 1, vk::DescriptorType::eCombinedImageSampler, &texDescInfo, nullptr, nullptr), vk::WriteDescriptorSet(descSets_[1], 1, 0, 1, vk::DescriptorType::eCombinedImageSampler, &postDescInfo, nullptr, nullptr), vk::WriteDescriptorSet(descSets_[1], 2, 0, 1, vk::DescriptorType::eCombinedImageSampler, &postDepthDescInfo, nullptr, nullptr), }; device.GetDevice().updateDescriptorSets(descSetInfos, nullptr);}

DX12でも思うことですが、Descriptor 関連はどういう設計でエンジンに組み込むのがセオリーなんでしょうね?

Vulkan だと Pool、SetLayout、Set を全部1対1関係で作成することも出来ますが、SetLayout はシェーダの組み合わせに対して1対1がいいような気がします。

Pool は描画するメッシュで1つ? Set はそのメッシュのDrawCall数(マテリアル数やシャドウ描画など含めて)分作成でしょうか?

ただ、同じメッシュを複数のカメラで描画する場合もあるでしょうし、その場合はHeapの切り替えだけで対応可能なDX12の方が理にかなってるようにも見えますね。

updaateDescriptorSets() 命令を毎フレーム複数回やっても問題ないようならさほど問題にならない気もしますが…どうなんでしょうね?

コードを公開しているエンジンをチェックするのが早いかなぁ…

最後に Pipeline オブジェクトも描画パス分だけ作成します。

このオブジェクトはDX12で言うところの Pipeline State Object (PSO) で、同じ描画パスに対して設定が変更される場合はその分だけ作成されなければなりません。

つまり、ブレンド方法が変わったりシェーダの組み合わせが変わったりしたらその分作成する必要があります。

DX12のPSOは Vulkan の Pipeline+RenderPass-SetLayout みたいな感じでしょうか。

Vulkan の Pipeline は SetLayout を参照するので、それが RootSignature に存在する DX12 とはちょっと違います。

ただ、PSO も Pipeline もシェーダの組み合わせごとに作成されるため、SetLayout はシェーダの組み合わせで1対1関係を作成できるので問題ないでしょう。

とはいえ、Descriptor周りとPipeline周りの設計がDX12とVulkanで微妙に違うのが悩ましい感じに見えますね。

ちゃんと作ればそれほど問題なく共通のラッパーライブラリが作れるのかもしれませんが…

で、Pipeline の作成については割愛。

main.cpp の 542行目からが MeshPass の Pipeline の作成、679行目からが PostPass の Pipeline の作成となっています。

最後に描画部分。

まあ、こちらも適切な RenderPass を適切に呼び出しているだけではありますが、一応コードを提示しておきます。

// メッシュパス開始 vk::RenderPassBeginInfo renderPassBeginInfo; renderPassBeginInfo.renderPass = meshPass_.GetPass(); renderPassBeginInfo.renderArea.extent = vk::Extent2D(kScreenWidth, kScreenHeight); renderPassBeginInfo.clearValueCount = 0; renderPassBeginInfo.pClearValues = nullptr; renderPassBeginInfo.framebuffer = offscreenFrame_; cmdBuffer.beginRenderPass(renderPassBeginInfo, vk::SubpassContents::eInline);{ // 略 // 前回を参照} cmdBuffer.endRenderPass();// オフスクリーンバッファのレイアウト変更{ vk::ImageSubresourceRange colorSubRange; colorSubRange.aspectMask = vk::ImageAspectFlagBits::eColor; colorSubRange.levelCount = 1; colorSubRange.layerCount = 1; offscreenBuffer_.SetImageLayout(cmdBuffer, vk::ImageLayout::eShaderReadOnlyOptimal, colorSubRange);}{ vk::ImageSubresourceRange depthSubRange; depthSubRange.aspectMask = vk::ImageAspectFlagBits::eDepth | vk::ImageAspectFlagBits::eStencil; depthSubRange.levelCount = 1; depthSubRange.layerCount = 1; depthBuffer_.SetImageLayout(cmdBuffer, vk::ImageLayout::eShaderReadOnlyOptimal, depthSubRange);}// ポストパス開始 renderPassBeginInfo.renderPass = postPass_.GetPass(); renderPassBeginInfo.renderArea.extent = vk::Extent2D(kScreenWidth, kScreenHeight); renderPassBeginInfo.clearValueCount = 0; renderPassBeginInfo.pClearValues = nullptr; renderPassBeginInfo.framebuffer = frameBuffers_[currentIndex]; cmdBuffer.beginRenderPass(renderPassBeginInfo, vk::SubpassContents::eInline);{ // 各リソース等のバインド vk::DeviceSize offsets = 0; cmdBuffer.bindDescriptorSets(vk::PipelineBindPoint::eGraphics, postPipeLayout_, 0, descSets_[1], nullptr); cmdBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, postPipeline_); cmdBuffer.draw(4, 1, 0, 0);} cmdBuffer.endRenderPass();

MeshPass は前回と同じです。

MeshPass 完了後、描画したオフスクリーンバッファと深度バッファのレイアウトを変更しています。

これは PostPass でシェーダリソースとして使用するためです。

PostPass では頂点バッファ、インデックスバッファの設定は行わずに draw() 命令を呼び出しています。

インデックスバッファは元々必要ないのですが、頂点シェーダでも頂点バッファによる入力は使用してないので問題ありません。

またちょっと長くなってしまいましたが、ここで今回の解説は終了です。

このサンプルはGeforceGTX1070で動作確認していますが、RadeonやIntelでは動作確認していません。

それらのGPUだとエラーや警告が出るかもしれませんが、もしおかしな部分がありましたら連絡いただけるとありがたいです。

次回はComputeShaderをやってみようかと思っています。

そこまで出来るとDX11でやってたことの大半はできるようになるので。