Vulkanを試してみた
はじめに
カブクの甘いもの担当、高橋憲一です。
エンジニアとしての担当領域はサーバーサイドで動作する3Dモデルデータの解析/レンダリングエンジンの開発です。
rinkakやMMSではユーザーの皆さんにアップロードして頂いた3Dモデルデータをプレビュー表示する機能があるのですが、その画像のレンダリングは mesa という OpenGL 互換のライブラリを使って開発したエンジンでサーバーサイドでオフスクリーンレンダリングをしています。で、今回は OpenGL ではなく、新しいグラフィクスAPIである Vulkan を Android N で試しみた話をしたいと思います。
Vulkanとは
AppleのMetalや、AMDのMantle、MicrosoftのDirect X12など、最近のモダンな3D グラフィクスAPIの潮流はドライバ層を薄くしてオーバーヘッドを減らすというものです。前述のOpenGLはこれまで長く、そして広く使われてきた3DグラフィクスAPIで、現在はKhronosという団体が管理と策定をしています。VulkanはそのKhronosが満を持して出してきた「モダンな」APIです。OpenGLが誕生したのは1992年、2003年にはモバイル機器などの組み込み用にOpenGL ESが出たりと何度かの変化を経て今に至りますが、最も大きな変化はシェーダーが導入されて、それまでの固定パイプラインがプログラマブルになった時かと思います。
私がOpenGLを初めて使ったのはちょうど20年前のことで(mesaもその当時からありました。それもまた凄いことかと)、最初に触った当時は、
glBegin(GL_TRIANGLES); /* 三角形群を描画する指定 */
glVertex3f(x0, y0, z0); /* 頂点 1 */
glVertex3f(x1, y1, z1); /* 頂点 2 */
glVertex3f(x2, y2, z2); /* 頂点 3 */
glEnd();
というようにして頂点ごとに関数を呼んで x, y, z の座標を指定するというスタイルでした。やがてVertex Bufferが導入されて頂点データをGPUに一度に転送するようになり、それをVBO (Vertex Buffer Object)としてオブジェクトをIDで管理して何度も頂点データをロードしなくても良くなったり、頂点毎、ピクセル毎の処理をシェーダーで記述するようになったり…といった変化がこれまでありました。(カブクのサービスであるrinkakやMMSのプレビュー画面で床面に影を落とすドロップシャドウの処理でもシェーダーを活用しています)
…と、老害的な話はこれくらいにしてVulkanの話に入りたいと思います。
開発環境のセットアップ
GoogleのNDKガイドのVulkan Setupにも載っているように、AndroidでVulkanを試すにはハードウェアとしてNexus 5X, Nexus 6P, Nexus Player のいずれかが必要で、Android NのDeveloper Preview 2以降をインストールする必要があります。
開発環境は、
- Android Studio (バージョンは 2.1 以上)
- NDK r12 以上 (現時点ではVulkanを使うにはCもしくはC++で実装する必要があります)
が必要です。
まずはTeapotから…
3Dグラフィクスといえば、まずはTeapotの描画からです。その割にはTeapotを使ったVulkanのサンプルは見当たりませんので肩慣らしにやってみました。描画結果はトップ画像の通りです。
ではコードの中を見ていきましょう。
(以降のコードはGoogleから出ているサンプルや、Khronosから出ているリファレンスを参考に、Teapotのデータをロードしてタップ操作で回せるように実装してみたものの断片です)
Vertexのロード
GPU側のメモリを割り当て、アプリケーション側からアクセスできるようにするためにメインメモリ空間のアドレスにマッピング、マッピングされたアドレスに頂点座標データを書き込む、という処理を行います。このようにVulkanのAPIの呼び出しは必要な構造体のメンバに値を設定して、その構造体を渡して何らかの結果を得るというものが多いです。
uint32_t dataSize = vertexDataSize + normalDataSize;
VkBufferCreateInfo buf_info{
.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO,
.pNext = NULL,
.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,
.size = dataSize, // 割当サイズを指定
.queueFamilyIndexCount = 0,
.pQueueFamilyIndices = NULL,
.sharingMode = VK_SHARING_MODE_EXCLUSIVE,
.flags = 0,
};
VkResult res = vkCreateBuffer(device_, &buf_info, NULL, &vertex_buffer.buf);
VkMemoryRequirements mem_reqs;
vkGetBufferMemoryRequirements(device_, vertex_buffer.buf,
&mem_reqs);
VkMemoryAllocateInfo alloc_info = {};
alloc_info.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
alloc_info.pNext = NULL;
alloc_info.memoryTypeIndex = 0;
alloc_info.allocationSize = mem_reqs.size;
pass = memory_type_from_properties(mem_reqs.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT,
&alloc_info.memoryTypeIndex);
// GPU側メモリの割り当て
res = vkAllocateMemory(device_, &alloc_info, NULL,
&(vertex_buffer.mem));
vertex_buffer.buffer_info.range = mem_reqs.size;
vertex_buffer.buffer_info.offset = 0;
uint8_t *pData;
// メインメモリのアドレス空間へのマッピング
res = vkMapMemory(device_, vertex_buffer.mem, 0, mem_reqs.size, 0,
(void **)&pData);
const float *vData = vertexData; // 頂点座標 (x0, y0, z0, x1, y1, z1, ...)
const float *nData = normalData; // 法線ベクトル (nx0, ny0, nz0, ny1, ny1, ny2, ...)
float *vBuf = (float *)pData;
uint32_t elementNum = vertexDataSize / (sizeof(float) * 3);
// GPU側のメモリにコピー
for (int i = 0; i < elementNum; i++) {
// vertex
*vBuf++ = *vData++;
*vBuf++ = *vData++;
*vBuf++ = *vData++;
// normal
*vBuf++ = *nData++;
*vBuf++ = *nData++;
*vBuf++ = *nData++;
}
vkUnmapMemory(device_, vertex_buffer.mem);
res = vkBindBufferMemory(device_, vertex_buffer.buf, vertex_buffer.mem, 0);
この辺りは自分でOpenGLのVBO (Vertex Buffer Object)の機能を実装しているかのような感覚があります。
同様にして三角形を定義する頂点データへのインデックスも usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT を設定してロードします。
パイプラインの生成とシェーダー
三角形のリストを描画する指定と、頂点を処理するバーテックスシェーダー、およびピクセルを処理するフラグメントシェーダーを関連付けてパイプラインを生成します。(シェーダーをロードするコードはここでは省略しています)
VkPipelineInputAssemblyStateCreateInfo ia{ // パイプラインインプットアセンブリ *1
.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO,
.pNext = NULL,
.flags = 0,
.primitiveRestartEnable = VK_FALSE,
.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST, // 三角形のリストの指定
};
// パイプラインの生成とシェーダーの関連付け
VkPipelineShaderStageCreateInfo shaderStages[2] { // シェーダーステージ *2
{
.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
.stage = VK_SHADER_STAGE_VERTEX_BIT,
.module = vertexShader, // バーテックスシェーダー
.pSpecializationInfo = nullptr,
.pName = "main",
},
{
.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
.stage = VK_SHADER_STAGE_FRAGMENT_BIT,
.module = fragmentShader, // フラグメントシェーダー
.pName = "main",
}
};
VkGraphicsPipelineCreateInfo pipelineInfo{
.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
.pNext = NULL,
.layout = pipelineLayout,
.basePipelineHandle = VK_NULL_HANDLE,
.basePipelineIndex = 0,
.flags = 0,
.pVertexInputState = &vi,
.pInputAssemblyState = &ia, // パイプラインインプットアセンブリ *1
.pRasterizationState = &rs,
.pColorBlendState = &cb,
.pTessellationState = NULL,
.pMultisampleState = &ms,
.pDynamicState = &dynamicState,
.pViewportState = &vp,
.pDepthStencilState = &ds,
.pStages = shaderStages, // シェーダーステージ *2
.stageCount = 2,
.renderPass = render_pass,
.subpass = 0,
};
// パイプラインの生成
res = vkCreateGraphicsPipelines(device_, pipelineCache, 1,
&pipelineInfo, NULL, &pipeline);
コマンドバッファ
パイプラインと関連付けて、ロードした頂点データを使用して描画するための定義をするのがコマンドバッファです。
VkRenderPassBeginInfo rp_begin{
.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,
.pNext = NULL,
.renderPass = render_pass,
.framebuffer = framebuffers[i],
.renderArea.offset.x = 0,
.renderArea.offset.y = 0,
.renderArea.extent.width = width,
.renderArea.extent.height = height,
.clearValueCount = 2,
.pClearValues = clear_values,
};
vkCmdBeginRenderPass(cmdBuffer[i], &rp_begin, VK_SUBPASS_CONTENTS_INLINE);
// パイプラインとコマンドバッファの関連付け
vkCmdBindPipeline(cmdBuffer[i], VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline);
vkCmdBindDescriptorSets(cmdBuffer[i], VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout, 0, NUM_DESCRIPTOR_SETS,
desc_set.data(), 0, NULL);
const VkDeviceSize offsets[1] = {0};
// 頂点バッファの指定
vkCmdBindVertexBuffers(cmdBuffer[i], 0, 1, &vertex_buffer.buf, offsets);
// インデックスバッファの指定
vkCmdBindIndexBuffer(cmdBuffer[i], indexBuf, offsets[0], VK_INDEX_TYPE_UINT16);
// インデックスを使用した描画(drawElementNumで要素数を指定)
vkCmdDrawIndexed(cmdBuffer[i], drawElementNum, drawInstanceNum, 0, 0, 0);
vkCmdEndRenderPass(cmdBuffer[i]);
VkImageMemoryBarrier prePresentBarrier {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.pNext = NULL,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT,
.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.subresourceRange.baseMipLevel = 0,
.subresourceRange.levelCount = 1,
.subresourceRange.baseArrayLayer = 0,
.subresourceRange.layerCount = 1,
.image = buffers[i].image,
};
vkCmdPipelineBarrier(cmdBuffer[i], VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, NULL, 0,
NULL, 1, &prePresentBarrier);
res = vkEndCommandBuffer(cmdBuffer[i]);
補足として、マルチスレッド環境での最適化が不得意だったOpenGLと異なり、このコマンドバッファをスレッドごとに生成して処理ができるということもVulkanの大きな利点の一つです。ARMのMALIのデベロッパー向けサイトにマルチスレッドを活用した良いサンプルがあります。
雑感
teapotを描画するために如何にたくさんのコードを書く必要があるかということを示す形(これでまだ3分の1くらいです)になってしまいましたが、ドライバの層が薄くなったということは、これまでドライバがやってくれていた処理をアプリケーション側で実装するということなのだと改めて実感した次第です。これはその分だけ、メモリの割り当てや描画命令の実行タイミングなどの細かい制御が可能になっているということになるので、頑張ればGPUの持つパフォーマンスを限界まで絞り出せるということです。
最後に
SGIのグラフィクスワークステーションに始まり、ケータイ、そしてAndroidやiPhoneと、プラットフォームは変わりながらもOpenGLとのつきあいが長い私としては、その新世代版と言えるVulkanを試さずにはいられず書いたのがこのブログ記事です。せっかくパフォーマンスを上げるための新しいAPIなのですから、Teapotを一つ表示して回しておしまいではなく、その限界を見てみたいのと、OpenGL ESとはどのくらい差がでるのか試してみたいと思っています。(なんて曖昧な言い方をしていると、マスター・ヨーダに "Do or do not. There is no try." と怒られそうなので「次の自分の番でやります」)
そして一番言いたいことは、カブクはそんなZ軸ジャンキーなエンジニアも活躍できる場所だということです 😉
その他の記事
Other Articles
関連職種
Recruit