Vulkanの話 第2回
画面クリアまで 16/07/16 up
第2回目です。
今回は画面クリアまでやってSample001の内容を終了させようと思います。
前回はVulkanデバイスを初期化し、ウィンドウを作成するところまで解説しました。
次に解説するのはスワップチェインの作成です。
今回のサンプルでは流れをわかりやすくするためにほとんどのオブジェクトをグローバル変数として保存しています。
が、スワップチェインは管理がいろいろ面倒だったので、スワップチェインクラスを作成して、必要なオブジェクトをこちらで管理するようにしました。
スワップチェインの実体自体はグローバル変数においてありますが。
さて、まずはメンバ変数を見てみましょう。
class MySwapchain
{
public:
struct Image
{
vk::Image image;
vk::ImageView view;
vk::Fence fence;
}; // struct Image
private:
vk::SurfaceKHR m_surface;
vk::SwapchainKHR m_swapchain;
vk::PresentInfoKHR m_presentInfo;
std::vector<Image> m_images;
vk::Format m_format;
vk::ColorSpaceKHR m_colorSpace;
uint32_t m_imageCount{ 0 };
uint32_t m_currentImage{ 0 };
uint32_t m_graphicsQueueIndex;
}; // class MySwapchain
MySwapchainクラスがスワップチェインを定義しています。
主に内部で使うImage構造体はVulkanのImageとImageView、そしてそのイメージに使用するフェンスを定義します。
このImageの実体はスワップチェインで利用するバックバッファの枚数分だけ作成します。
その他のメンバについてはおいおい解説しましょう。
初期化部分を見ていきましょう。
bool Initialize()
{
{
vk::Win32SurfaceCreateInfoKHR surfaceCreateInfo;
surfaceCreateInfo.hinstance = g_hInstance;
surfaceCreateInfo.hwnd = g_hWnd;
m_surface = g_VkInstance.createWin32SurfaceKHR(surfaceCreateInfo);
if (!m_surface)
{
return false;
}
}
// サポートされているフォーマットを取得
auto surfaceFormats = g_VkPhysicalDevice.getSurfaceFormatsKHR(m_surface);
if (surfaceFormats[0].format == vk::Format::eUndefined)
{
m_format = vk::Format::eR8G8B8A8Unorm;
}
else
{
m_format = surfaceFormats[0].format;
}
m_colorSpace = surfaceFormats[0].colorSpace;
m_graphicsQueueIndex = FindQueue(vk::QueueFlagBits::eGraphics, m_surface);
if (m_graphicsQueueIndex == kQueueIndexNotFound)
{
return false;
}
return true;
}
最初にSurfaceを生成します。
Surfaceは画面を定義するオブジェクトのようですが、これはプラットフォームにより管理方法などが異なります。
この初期化の方法ではWindows用の初期化しか出来ませんが、他のプラットフォームでは別の手法で作成してください。
とはいえ、最後に取得できるのはvk::SurfaceKHRオブジェクトです。
そこからサポートされているフォーマットを取得します。
本来であれば使用したいフォーマットがサポートされているかチェックし、サポートされていればOK,されていなければ別のフォーマットを選ぶなどが必要なのだろうと思います。
あとはグラフィクスキューのインデックスを取得するわけですが、生成したSurfaceがサポートされているかチェックします。
まあ、サポートされてないってことになることはまずないと思いますが。
次にSwapchainオブジェクトの初期化部分を見ていきましょう。
クラスの初期化と分けている理由としては、解像度変更によってスワップチェインを作りなおす場合があるためです。
以下のコードは当該関数ですが、長くなりすぎるので大幅に省略しています。
bool InitializeSwapchain(uint32_t width, uint32_t height, bool enableVSync)
{
vk::SwapchainKHR oldSwapchain = m_swapchain;
m_currentImage = 0;
vk::SurfaceCapabilitiesKHR surfaceCaps = g_VkPhysicalDevice.getSurfaceCapabilitiesKHR(m_surface);
auto presentModes = g_VkPhysicalDevice.getSurfacePresentModesKHR(m_surface);
vk::Extent2D swapchainExtent(width, height);
// Presentモードを選択する
vk::PresentModeKHR swapchainPresentMode = vk::PresentModeKHR::eFifo;
if (!enableVSync)
{
// 略
}
// イメージ数を決定する
uint32_t desiredNumSwapchainImages = surfaceCaps.minImageCount + 1; // 最小イメージ数に+1しておく(たいてい、トリプルバッファになる)
// サーフェイスのトランスフォームを決定
// 通常はIdentityでいいはずだが、スマホとかだとそれ以外があるのかも?
vk::SurfaceTransformFlagBitsKHR preTransform = vk::SurfaceTransformFlagBitsKHR::eIdentity;
// Swapchainの生成
{
vk::SwapchainCreateInfoKHR swapchainCreateInfo;
// 略
m_swapchain = g_VkDevice.createSwapchainKHR(swapchainCreateInfo);
if (!m_swapchain)
{
return false;
}
}
// 前回のSwapchainが残っている場合は削除
// 略
// スワップチェインが持っているImageを取得する
vk::ImageViewCreateInfo viewCreateInfo;
viewCreateInfo.format = m_format;
viewCreateInfo.subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
viewCreateInfo.subresourceRange.levelCount = 1;
viewCreateInfo.subresourceRange.layerCount = 1;
viewCreateInfo.viewType = vk::ImageViewType::e2D;
auto swapChainImages = g_VkDevice.getSwapchainImagesKHR(m_swapchain);
m_imageCount = static_cast<uint32_t>(swapChainImages.size());
// Swapchainの各イメージに対してViewとFenceを作成
m_images.resize(m_imageCount);
for (uint32_t i = 0; i < m_imageCount; i++)
{
m_images[i].image = swapChainImages[i];
viewCreateInfo.image = swapChainImages[i];
m_images[i].view = g_VkDevice.createImageView(viewCreateInfo);
m_images[i].fence = vk::Fence();
}
return true;
}
解像度が変更された場合などはスワップチェインを作成しなおす場合があります。
そのため、以前のスワップチェインは一旦退避しておき、新しいスワップチェイン作成後に破棄するようにします。
PresentモードはVSyncの有無やSurfaceがサポートしているかどうかなどで変化します。
WindowモードでVSync待ちを行わない場合はeMailboxにするのがいいのかな。
スワップチェインで作成するイメージ数はvk::SurfaceCapabilitiesKHR::minImageCount以上でなければなりません。
Windowsであればほとんどの場合で 2 が入ってくると思いますので、最低2枚作成しましょう。今回は+1して3枚作成します。
vk::SwapchainCreateInfoKHRの必要事項を埋めて生成を行えばスワップチェイン自体の生成は完了です。
過去のスワップチェインを破棄して、スワップチェインが管理するImageに対してImageViewを作成し、フェンスを初期化して完了です。
以下の3つのメンバ関数はバッファのスワップに関する命令です。
uint32_t AcquireNextImage(vk::Semaphore presentCompleteSemaphore)
{
auto resultValue = g_VkDevice.acquireNextImageKHR(m_swapchain, UINT64_MAX, presentCompleteSemaphore, vk::Fence());
assert(resultValue.result == vk::Result::eSuccess);
m_currentImage = resultValue.value;
return m_currentImage;
}
vk::Result Present(vk::Semaphore waitSemaphore)
{
m_presentInfo.waitSemaphoreCount = waitSemaphore ? 1 : 0;
m_presentInfo.pWaitSemaphores = &waitSemaphore;
return g_VkQueue.presentKHR(m_presentInfo);
}
vk::Fence GetSubmitFence(bool destroy = false)
{
auto& image = m_images[m_currentImage];
while (image.fence)
{
// Fenceが有効な間は完了するまで待つ
vk::Result fenceRes = g_VkDevice.waitForFences(image.fence, VK_TRUE, kFenceTimeout);
if (fenceRes == vk::Result::eSuccess)
{
if (destroy)
{
g_VkDevice.destroyFence(image.fence);
}
image.fence = vk::Fence();
}
}
image.fence = g_VkDevice.createFence(vk::FenceCreateFlags());
return image.fence;
}
AcquireNextImage() 命令はバックバッファのスワップを行い、次に描画されるべきImageのインデックスを返します。
この命令によってシステムが処理を正常に終えてバックバッファへのアクセスが可能になるとセマフォにシグナルが送られるか、フェンスが解除されます。。
Present() 命令はそのままPresentを行う命令です。
通常は前述のAcquireNextImage() とワンセットで用いることになるはずです。
この際に指定するセマフォは AcquireNextImage() で利用したセマフォとなります。
GetSubmitFence() 命令はSubmit時に使用するImageのフェンスを取得します。
フェンスがすでに作成済みであればフェンスが解かれるまで待ち、その後にフェンスを破棄、それから再作成してフェンスを返します。
スワップチェインの解説はこんなもんで。次は描画に必要ないくつかのオブジェクトの作成を見ていきます。
bool InitializeRenderSettings()
{
// セマフォ設定
{
vk::SemaphoreCreateInfo semaphoreCreateInfo;
// Presentの完了を確認するため
g_VkPresentComplete = g_VkDevice.createSemaphore(semaphoreCreateInfo);
// 描画コマンドの処理完了を確認するため
g_VkRenderComplete = g_VkDevice.createSemaphore(semaphoreCreateInfo);
if (!g_VkPresentComplete || !g_VkRenderComplete)
{
return false;
}
}
// コマンドバッファ作成
{
vk::CommandBufferAllocateInfo allocInfo;
allocInfo.commandPool = g_VkCmdPool;
allocInfo.commandBufferCount = g_VkSwapchain.GetImageCount();
g_VkCmdBuffers = g_VkDevice.allocateCommandBuffers(allocInfo);
}
return true;
}
画面クリアを行うだけならセマフォとコマンドバッファがあれば十分です。
セマフォはPresentの完了を受け取るためのものと描画コマンドの処理完了を受け取るためのものです。
コマンドバッファは実際にコマンドを積み込むためのバッファです。
ここで生成するコマンドバッファはメインのコマンドバッファといえる代物なので、バックバッファ分だけ作成して使いまわしていきます。
最後に描画ループを見ていきます。
void DrawScene()
{
// 次のバックバッファを取得する
uint32_t currentBuffer = g_VkSwapchain.AcquireNextImage(g_VkPresentComplete);
// コマンドバッファの積み込み
{
auto& cmdBuffer = g_VkCmdBuffers[currentBuffer];
cmdBuffer.reset(vk::CommandBufferResetFlagBits::eReleaseResources);
vk::CommandBufferBeginInfo cmdBufInfo;
cmdBuffer.begin(cmdBufInfo);
vk::ClearColorValue clearColor(std::array<float, 4>{ 0.0f, 0.0f, 0.5f, 1.0f });
// カラーバッファクリア
{
MySwapchain::Image& colorImage = g_VkSwapchain.GetImages()[currentBuffer];
vk::ImageSubresourceRange subresourceRange;
subresourceRange.aspectMask = vk::ImageAspectFlagBits::eColor;
subresourceRange.levelCount = 1;
subresourceRange.layerCount = 1;
// カラーバッファクリアのため、レイアウトを変更する
SetImageLayout(
cmdBuffer,
colorImage.image,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eTransferDstOptimal,
subresourceRange);
// クリアコマンド
cmdBuffer.clearColorImage(colorImage.image, vk::ImageLayout::eTransferDstOptimal, clearColor, subresourceRange);
// Presentのため、レイアウトを変更する
SetImageLayout(
cmdBuffer,
colorImage.image,
vk::ImageLayout::eTransferDstOptimal,
vk::ImageLayout::ePresentSrcKHR,
subresourceRange);
}
cmdBuffer.end();
}
// Submit
{
vk::PipelineStageFlags pipelineStages = vk::PipelineStageFlagBits::eBottomOfPipe;
vk::SubmitInfo submitInfo;
submitInfo.pWaitDstStageMask = &pipelineStages;
// 待つ必要があるセマフォの数とその配列を渡す
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &g_VkPresentComplete;
// Submitするコマンドバッファの配列を渡す
// 複数のコマンドバッファをSubmitしたい場合は配列にする
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &g_VkCmdBuffers[currentBuffer];
// 描画完了を知らせるセマフォを登録する
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &g_VkRenderComplete;
// Queueに対してSubmitする
vk::Fence fence = g_VkSwapchain.GetSubmitFence(true);
g_VkQueue.submit(submitInfo, fence);
vk::Result fenceRes = g_VkDevice.waitForFences(fence, VK_TRUE, kFenceTimeout);
assert(fenceRes == vk::Result::eSuccess);
}
// Present
g_VkSwapchain.Present(g_VkRenderComplete);
}
最初にスワップチェインのAcquireNextImage() を実行して次のバックバッファインデックスを取得します。
コマンドバッファやスワップチェインのイメージはこのインデックスのものを利用します。
次にコマンドバッファへのコマンドの積み込みを行います。
積み込みを行う前にバッファをリセットして空にしますが、この際にまだコマンドバッファが使用されていたりするとエラーが発生します。
少なくともGeForceだとクラッシュはしませんが、よろしくない動作なので注意してください。
今回積みこむコマンドはカラーバッファ(つまりバックバッファ)のクリアだけです。
スワップチェインからイメージを取得してクリアコマンドを発行するのですが、その前後にイメージレイアウトの変更を行っています。
クリアする前にはレイアウトを eTransferDstOptimal に変更し、転送先として使用できるレイアウトに変更します。
クリア後には ePresentSrcKHR に変更します。これはPresent時にフレームバッファに利用する元バッファとしてのレイアウトを意味します。
イメージレイアウトと言われてもピンと来ない人も多いかと思いますが、実は自分もいまいちピンときてません。
リファレンスを読んだ理解としては、イメージのバッファをどう使うかについてはGPUベンダーに任されていてい、ユーザに対しては不透明な形で実装されているようです。
例えばリニアとタイルなんかはその典型だと思うのですが、同じイメージでも使用用途によってはレイアウトを変更してバッファの整理を行った方が効率がいい場合もあるでしょう。
これまではそのような内部実装がユーザから完全に隠されていましたが、VulkanやDX12によってそうも言ってられなくなりました。
あるGPUでは描画バッファはリニア、テクスチャはタイルでなければならない実装だったとしましょう。
しかし別のGPUではどちらもタイルレイアウトで、描画バッファをテクスチャとして使用する際にレイアウトの変更が不要だったとします。
このような場合、このGPUごとのレイアウトの違いをチェックし、変更するかしないか、変更するならどれに変更するかを設定させるのはなかなか酷です。
Vulkanではこれをパイプラインバリアで実現してくれています。
つまり、ある状態から別の状態に遷移する際にバリアを張り、遷移が終了した段階で解除してくれます。これによりリソースハザードを回避します。
ユーザが指定するのはどの状態からどの状態に遷移するかだけです。あとはGPUがレイアウト変更が必要なら勝手にやってくれます。
多分こんな理解で正しいと思うのですが、間違っているのであれば指摘していただくとありがたいです。
さて、コマンドが積み込み終わったらSubmitを行います。
vk::SubmitInfoに必要な情報を設定し、バックバッファ用のフェンスを利用してSubmitを行います。
vk::SubmitInfo::pSignalSemaphoresにRenderCompleteのセマフォを設定しています。
これによってコマンドの処理が完了するとセマフォにシグナルが送られ、Present時にそのシグナルを待つようになるのかと思っていたのですが、どうもそういう動作をしてくれませんでした。
Submit後にフェンスの解除を待っていますが、これを行わないとエラーが発生しました。
もしくはQueueがアイドル状態になるのを待つことで対応できますが、セマフォは意味ないかもしれません。
あとはこの描画処理を毎フレーム回せば画面がブルーでクリアされるはずです。
エラーが発生していないことも確認しましょう。GPUベンダーやドライバのバージョンによってはエラー出るかもしれません。
というわけであまり解説になっていなかった気がしますが、Vulkanで画面クリアまでやってみました。
次はとりあえず頂点バッファ、Uniformバッファ、シェーダを利用して、適当なポリゴンを描画してみます。
こちらはすでにコミット済みのSample002で行っていますので、解説待てない方はそちらをチェックしてみてください。