Rust
Vulkan

Rust向けゲームライブラリ/エンジン「Interlude/Postludium」について

More than 1 year has passed since last update.

17/01/23: 現在は内部構造を大幅に改修したので、この記事に書いてある情報は古いものとなっています。この記事に書かれているものはcommit b3e16f51f74157fe44cb9bb4cf3b29e7f50956ac以前で有効な情報となります。

現在Unityで制作したゲームの移植を進めているのと並行して、新しい内製ゲームライブラリ/エンジンであるInterludePostludiumを開発しています。オレオレエンジンも歓迎とのことなのでエンジン内部の話とかはこちらで投稿させていただきます。Rust言語寄りの話や表層の話などはRustアドベントカレンダーの同日の記事を参照してください。

概要

EngineOverview.png

Interlude/Postludium Engine(本来はEngineという語は付きませんが、ここでは区別のためにPostludium Engineと記述します)はRust言語の上に構築されたマルチメディアライブラリとエンジン(ミドルウェア)の組み合わせです。ベースとなるグラフィックスサブシステムとしてVulkanを採用しており、マルチプラットフォーム対応かつハイパフォーマンスなアプリケーションを作成可能となっています。ただ、現在はLinux/X11環境向けのみ完全対応となっており、Windows環境は部分的に対応している感じになります(具体的には入力システムが未完成です)。
両者ともオープンソース(MITライセンス)となっており、GitHub上にて絶賛開発中となっています。
- Interlude: Pctg-x8/interlude
- Postludium Engine: Pctg-x8/postludium

Interludeについて

Interludeは単純な薄いレイヤーとなっていて、Vulkanと各プラットフォームのAPI群(WindowsではWin32API、LinuxではX11をXCB経由で、あとはPOSIXの関数群を利用しています)を包括して抽象化しています。実際のところはInterludeのみですでにクロスプラットフォームなゲーム開発を行えるのですが、現在は補助的な役割のものとしてPostludium Engineをセットで開発しています(ゆくゆくはメインとなる予定)。

Postludium (Engine)について

Postludium EngineはVulkanおよび次世代グラフィックスAPIの仕様に合わせて設計された新しいタイプのゲームエンジンです。Interludeの上位に乗る割とぶ厚めのレイヤーで、Interlude(Vulkan)を通したハードウェア(GPU/CPU)に対するかなり抽象化された操作を提供します。
将来的にはほぼスタンドアロンでの動作(初期化部分の自動化)を目指しています。ゲームロジック部分をどうしてもRustで書く必要があるためそれとの連携をどうするかというのが当分の課題です。

GPU Device Configuration Processor

VulkanではGPUに対して実行前に様々な設定を施すことで高度な最適化を可能としています。Postludiumのこのシステムは、Vulkanの大部分のAPIを「GPUに対するコンフィグレーション」と解釈し、それらを設定ファイルを専用の構文を用いて人間に理解しやすい形で記述することを可能とします。現時点ではイメージまわりのコンフィグレーションのみ実装されていますが、将来的にはパイプラインステートも記述できるようにする予定です。
いまのところは次のような記述が可能となっています。

# ここはコメント行です

# 2Dのレンダーターゲットを記述します
# $ScreenWidth, $ScreenHeightは特殊な値で、ロード時に指定された値に置き換わります
# 2Dイメージはデフォルトでステージングメモリに領域が確保されますが、
# これが不要な場合はDeviceLocalを指定します
Image 2D
- Format: R8G88A8 UNORM
- Extent: $ScreenWidth $ScreenHeight
- Usage: Sampled / ColorAttachment / DeviceLocal
# Rust側からは`imgconf.images_2d()[0]`として参照することができます
DrawCall Builder

構造化された専用構文を使用してコマンドバッファの記述の補助をします。こちらはまだ微妙に開発中です(もう少しでゲーム側に試しに載せられるようになるはず)。具体的には以下のようにコマンドの記述が可能です。

const DefaultIndices = 0
DrawState TriangleRenderDS
{
  PipelineState: ps[DefaultIndices],
  DescriptorSets: { ds[0] },
  PushConstants: { 0: 1.0f, 1: 2.0f, 2: 0.5f, 3: 1.0f },
  VertexBuffers: { vb.0 }
}
DrawState TriangleRenderDS2 : TriangleRenderDS
{
  PushConstants: { 0: 0.0f, 2: 1.0f }
}
# Backbufferへの描画がProceduralTexture1より後に起こるようにします
DrawOrder ProceduralTexture1 >> Backbuffer

# `PrimaryCommandList Main for Backbuffer-0`と同義
FramebufferCommandList
{
  Draw<TriangleRenderDS> 3, 1
  Draw<TriangleRenderDS2> 3, 2
  ---
  Draw<_PostprocessingDS> 4, 1
}

PrimaryCommandList Main for ProceduralTexture1-0
{
  Draw<TriangleRenderDS> 3, 1
}

Interludeを使用する利点とか

  • エンジンの初期化と同時にウィンドウも生成されるようにはなっておらず、自分で生成する必要があります。初期化に行数を食いますがマルチウィンドウ対応などが簡単にできるようになっています(Interludeが「マルチメディアライブラリ」となっている理由)
  • 入力システムが強力かつ柔軟なつくりになっています。一般的なゲームエンジン/ライブラリでは受け取りたいキーを直接指定して処理する方式が多いですが、Interludeはマルチプラットフォーム対応を目指しているためUnityに近い形のマッピングシステムを併用しています
  • Rust言語を利用することにより高い安全性/堅牢さの実現とマルチスレッド対応がなされています。後者はかなり限定的ですが(ウィンドウシステムはいろいろな都合上エンジンと同一のスレッドでのみ使用可能となっています)
  • グラフィックスサブシステム(Vulkan)に対して効率と生産性を最大限引き出せるようなラッピングを施しています。高度な調整が必要な部分はほとんどVulkanそのままみたいなAPIとなっていますが、例えばバッファメモリ確保などは操作がほぼ同じなので手順をまとめてあります

Interlude 全体像と階層

(長いので必要なければ読み飛ばしてください。ただ、以降の説明にここで挙げた名前を使用します)

  • interlude
    • engine: エンジン本体
      • struct DeviceFeatures
      • trait EngineCore
      • trait CommandSubmitter
      • struct Engine
    • synchronize: CPU-GPUあるいはキュー間同期オブジェクト
      • struct QueueFence (=Semaphore)
      • struct Fence
      • struct FenceRef (削除候補)
    • framebuffer: RenderPassとFramebuffer
      • struct AttachmentDesc
      • type AttachmentRef (実は単にVkAttachmentReferenceそのもの)
      • struct PassDesc
      • struct PassDependency
      • struct AttachmentClearValue
      • struct RenderPass
      • struct Framebuffer
    • command: コマンドバッファ関係
      • struct MemoryBarrier
      • struct BufferMemoryBarrier
      • struct ImageMemoryBarrier
      • struct IndirectCallParameter
      • struct BufferCopyRegion
      • struct ImageCopyRegion
      • struct ImageBlitRegion
      • struct GraphicsCommandRecorder
      • struct TransferCommandRecorder
      • trait PrimaryCommandBuffers
      • trait SecondaryCommandBuffers
      • trait DrawingCommandRecorder
      • struct GraphicsCommandBuffers
      • struct BundledCommandBuffers (=セカンダリコマンドバッファ)
      • struct TransferCommandBuffers
      • struct TransientTransferCommandBuffers
      • struct TransientGraphicsCommandBuffers
    • resource: リソース(イメージとかバッファとか)
      • struct ImageSubresourceRange
      • struct ImageSubresourceLayers
      • enum BufferDataType
      • enum ImageUsagePresets(関数でプリセットを提供してる)
      • struct ImageDescriptor1 (2と3もある。末尾の数字はイメージの次元数)
      • struct SamplerState
      • enum ComponentSwizzle
      • struct ComponentMapping
      • enum Filter
      • trait ImageDescriptor
      • trait ImageView
      • trait BufferResource
      • trait ImageResource
      • trait ImageViewFactory
      • struct Buffer
      • struct Image1D (もちろん2D, 3Dもある)
      • struct LinearImage2D (ステージングオブジェクト用)
      • struct DeviceBuffer
      • struct StagingBuffer
      • struct DeviceImage
      • struct StagingImage
      • struct MemoryMappedRange
      • struct ImageView1D (2D, 3Dもある)
      • struct Sampler
      • struct BufferPreallocator
      • struct ImagePreallocator
    • shading: シェーディングパイプライン関係
      • struct PipelineShaderProgram
      • enum ConstantEntry
      • struct VertexBinding
      • struct VertexAttribute
      • struct PushConstantDesc
      • enum PrimitiveTopology
      • struct ViewportWithScissorRect
      • struct RasterizerState
      • enum AttachmentBlendState
      • struct GraphicsPipelineBuilder
      • struct ShaderProgram
      • struct PipelineLayout
      • struct GraphicsPipeline
    • descriptor: DescriptorSetとPool
      • enum ShaderStage
      • enum Descriptor
      • struct BufferInfo
      • struct ImageInfo
      • enum DescriptorSetWriteInfo
      • struct DescriptorSetLayout
      • struct DescriptorSets
    • debug_info: DebugInfo
      • struct DebugInfo
      • enum DebugLine
    • input: 入力システム
      • enum InputKeys
      • enum InputAxis
      • enum InputType
      • struct InputDevice
      • trait InputSystem
    • data: 共用データ構造(C互換レイアウトのVec4とか)
    • window_common: ウィンドウシステム共通
      • enum ApplicationState
      • trait WindowServer
      • trait RenderWindow
      • struct Window
      • struct WindowRenderTarget
    • vk: 内部的なVulkanラッパ
    • concurrent: マルチスレッド補助
      • struct Event (Win32APIのEventとPOSIXのeventfdの抽象化)

Interludeの初期化とEngineCoreトレイト

全部のサンプルを載せると記事が長くなりすぎてしまうため、とりあえず初期化回りについてだけサンプルコードを上げておきます。とりあえずというか微妙に癖のある初期化方法のため先に解説を書いておきます。

Interludeのコアとなる構造体はinterlude::Engineとなり、これを初期化することが第一歩となります。そのためにはEngineの関連関数であるinterlude::Engine::newを使います。宣言は以下の通りとなっています。

pub fn new<StrT: AsRef<Path>>(app_name: &str, app_version: u32, asset_base: Option<StrT>, extra_features: DeviceFeatures) -> Result<Self, EngineError>
  • app_nameおよびapp_versionはアプリの識別名およびバージョンを指定します。Vulkan初期化の関係でこれが必要になりますので適当に一意な名前を指定してください。app_versionについては、本当はVK_MAKE_VERSIONというマクロで生成するのが正しいのですが現在それに相当するマクロを用意していないので適当に指定してください(0x01とすればとりあえず0.0.1の意味になります)
  • asset_baseの型が少し複雑ですが、要するにパスとなりうる何か(普通は文字列&str)を指定するかNoneを渡すようにしています(詳しく知りたい方はぜひRustをやりましょう)。Interludeにも一応簡易的なアセット管理システムが存在していて、APIにファイルパスを渡すときはほとんどアセットディレクトリ内部の相対パスを指定するようになっています。{asset_base}/assetsがアセット参照の基準ディレクトリとなります。ここをNoneにするとasset_baseに実行ファイルと同じディレクトリが使用されます
  • extra_featuresにはデバイスの追加機能のうち使用したいものを指定します。現在はテクスチャの圧縮方式のブロック圧縮を使用できるか否かのみ指定できるようになっています(ジオメトリシェーダと間接コマンド実行中でのfirstInstance指定は内部(DebugInfo)で使用するためあらかじめ有効となっています)

返り値はエンジン本体もしくはエラーとなっています。Result<T, EngineError>となっているもの(Interludeの大部分のAPI)はinterlude::UnrecoverableExt<T>トレイトによってor_crash(self) -> Tメソッドが使用できるようになっているため、最終的に以下の文で初期化を完了することができます。

let engine = interlude::Engine::new("SampleGame", 0x01, None, interlude::DeviceFeatures::new()).or_crash();

サンプルではわかりやすくするためモジュール名を省略していませんが、もちろんuse interlude::*;と書くことで省略することも可能です。

さてこうして生成したEngineですが、他の関数に貸与する場合にも少し罠があります。interlude::Engineは内部的にはけっこうややこしい型になっているので普通にengine: &interlude::Engineで渡そうとしてもエラーになってしまいます。ここで登場するのがセクションタイトルにあるinterlude::EngineCoreトレイトで、これはEngineの中でもグラフィックスサブシステムにかかわるメソッドの提供をしています。なのでウィンドウ関係、入力関係を処理したい場合には別途手段を講じる必要がありますが(後述します)、それ以外であれば次のようにジェネリクスを使用して受け渡す、あるいは動的ディスパッチを用いることにして受け渡すことが可能です。

fn load_hogehoge_image<Engine: interlude::EngineCore>(engine: Engine, asset_ref: &str)
// もしくは
fn load_hogehoge_image(engine: &interlude::EngineCore, asset_ref: &str)

入力システム/ウィンドウシステムを別関数に渡したい場合は方法が2つあって、一つはinterlude::Engine::input_system_ref(&self) -> &Arc<RwLock<IS>>を利用して内部オブジェクトへの参照を得て渡すか、もう一つはジェネリクスで逐一指定する方法をとることになります。

// input_system_refの結果を渡す場合(かんたん)
fn process_inputs<IS: interlude::InputSystem<AppInputNames>>(input_sys: &Arc<RwLock<IS>>)
// Engine本体を渡す場合(ややこい)
fn process_inputs<WS: interlude::WindowServer, IS: interlude::InputSystem<AppInputNames>>(engine: &interlude::Engine<WS, IS, AppInputNames>)

BufferPreallocatorについて

Interludeの高度な抽象化の一つとしてBufferPreallocatorの存在があります。メモリを効率よく、かつ簡単に扱えるようにするためにInterludeでのバッファ生成はこの構造体(とinterlude::EngineCore::buffer_preallocateメソッド)を使用します。必要なバッファごとにメモリをいちいち確保しているとパフォーマンスに影響が出るのとフラグメンテーションが頻発してしまうため、できるだけメモリはまとめて確保するのが望ましいです。BufferPreallocatorはそれの手助けを行います。
BufferPreallocatorはinterlude::EngineCore::buffer_preallocateメソッドによって生成されます。

fn buffer_preallocate(&self, structure_sizes: &[(usize, BufferDataType)]) -> BufferPreallocator;

バッファのコンテンツの配列(これはバイトサイズとバッファタイプのタプルの配列です)を受け取り、interlude::BufferPreallocator構造体を生成します。これは各コンテンツのオフセットバイト数と必要メモリの総バイト数を持っており、その中身は例えば以下のようになっています。

PreallocatorVisual.png

Index Buffer AとUniform Buffer Aの間に空白がありますが、これはアラインメント制約によるものです。このように各バッファの必要アラインメントを考慮してコンテンツを配置、必要なバイト数を計算してくれるようになっています。
BufferPreallocatorを生成しただけでは実際にメモリ確保は行われません。メモリを確保するにはinterlude::EngineCore::create_double_bufferにBufferPreallocatorを渡す必要があります。

fn create_double_buffer(&self, prealloc: &BufferPreallocator) -> Result<(DeviceBuffer, StagingBuffer), EngineError>;

デバイスローカルなメモリとステージングメモリ(CPUから読み書きできるメモリ)を同時に確保してくれます。

ちなみにイメージ用のプリアロケータはないのかと気になるところですが、こちらもImagePreallocatorという名前で存在します。ただこちらは生成方法が若干違って、Rustでよく使われるBuilderパターンを踏襲したインターフェイスになっています。メモリ確保には似た名前のinterlude::EngineCore::create_double_imageを使用しますが、イメージオブジェクトの場合はそのすべてが必ずステージングメモリが必要というわけではなく、場合によっては不要なこともあるため返り値がResult<(DeviceImage, Option<StagingImage>), EngineError>となっています。必要に応じてunwrapをしてください。

DebugInfoによる簡易デバッグ出力

InterludeはFreeType2を使用した簡易的なデバッグ出力をサポートしています。本当に簡易的なものなので、例えば任意の文字列を出力などはできないのですが(パフォーマンスをある程度確保したいため)FPS値やオブジェクトの座標などの表示に使用することができます。例えばHardGrad -> Extendの開発中のバージョンでは次のようにしてFPS値やフレームタイムなどの出力をしています。

main.rs(一部)
let frames_per_second = RefCell::new(0.0f64);
let frame_time_ms = RefCell::new(0.0f64);
let cputime_ms = RefCell::new(0.0f64);
let enemy_count = RefCell::new(0u32);
let player_bithash = RefCell::new(0u32);
let debug_info = DebugInfo::new(&engine, &[
    DebugLine::Float("FPS".to_owned(), &frames_per_second, None),
    DebugLine::Float("Frame Time".to_owned(), &frame_time_ms, Some("ms".to_owned())),
    DebugLine::Float("CPU Time".to_owned(), &cputime_ms, Some("ms".to_owned())),
    DebugLine::UnsignedInt("Enemy Count".to_owned(), &enemy_count, None),
    DebugLine::UnsignedInt("Player Bithash".to_owned(), &player_bithash, None)
], &render_pass.smaa_combine, 0, &sc_viewport).or_crash();

DebugInfoは基本的に"{前置メッセージ}: {値} {単位}"を1行として管理します。DebugInfoに値を渡すと借用状態となるためメインルーチンで値を書き換えることができなくなってしまうためRefCellで囲って渡します(べつに問題にならなさそうな気がするのですが、これを組んだのがかなり前なのでなぜRefCellを使ったのかよくわからないです。外せそうだったらそのうち外します)。
あと、DebugInfoを生成する際にレンダーパスとサブパスインデックス、およびビューポートを渡す必要があります。内部で専用のパイプラインステートを作成する際に使用されます。

ちなみに、DebugInfoではOpenSansフォントを固定で使用することになっているため、アセットディレクトリ内engine/fontsディレクトリの中にOpenSans-Regular.ttfが存在している必要があります。

TODOとか

InterludeもPostludiumもまだ機能が不十分な部分が多いです。現状基本的にはゲームと並行して開発している都合上ゲーム側で必要となる機能を優先して実装することになりますが、もし「この機能があったらいいんじゃないかな」というものがありましたら記事のコメントとかGitHubでissue投げるとかしていただけると今後の参考になります(乞食)。
一応現在作成中の弾幕シューティングゲーム(HardGrad -> Extend)が完成したらInterlude/Postludiumはおしまい、というわけではなくて今後もう一本ゲームを製作する予定があって、他の主要な機能(シーンチェンジであったり一時的に不要なリソースのVRAMからのアンロードであったりパイプラインキャッシュサポートであったり)のほとんどはそこで実装される予定になっています。

あと、Interludeリポジトリのupstreamにはまだ入っていないですがPSDファイルのロードおよびBC4/5によるテクスチャ圧縮サポートはHardGradの側で部分的に実装されていてInterlude側に組み込まれる予定があるのでその辺りはおいおいという感じです(?

あとはD言語への移植とか。C++と、もしかしたらNimあたりにも移植したくなることもあるかもしれませんが今のところ後者2つの予定はありません。