17/01/23: 現在は内部構造を大幅に改修したので、この記事に書いてある情報は古いものとなっています。この記事に書かれているものはcommit b3e16f51f74157fe44cb9bb4cf3b29e7f50956ac以前で有効な情報となります。
現在Unityで制作したゲームの移植を進めているのと並行して、新しい内製ゲームライブラリ/エンジンであるInterludeとPostludiumを開発しています。オレオレエンジンも歓迎とのことなのでエンジン内部の話とかはこちらで投稿させていただきます。Rust言語寄りの話や表層の話などはRustアドベントカレンダーの同日の記事を参照してください。
概要
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の抽象化)
-
- engine: エンジン本体
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
構造体を生成します。これは各コンテンツのオフセットバイト数と必要メモリの総バイト数を持っており、その中身は例えば以下のようになっています。
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値やフレームタイムなどの出力をしています。
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つの予定はありません。