Rust Bevy 0.16 レンダリンググラフ再設計でパフォーマンス40%向上:実装詳解とマイグレーションガイド
Bevy 0.16で刷新されたレンダリンググラフ設計を詳解。新しいExtract/Prepare/Render分離アーキテクチャとパフォーマンス改善の仕組みを実装例とともに解説します。
約8分で読めますRust製ゲームエンジンBevyは、2026年3月にリリースされたバージョン0.16において、レンダリンググラフシステムの大規模な再設計を実施しました。この変更により、公式ベンチマークでフレーム生成時間が最大42%短縮され、特に複雑なシーンでのGPU同期オーバーヘッドが大幅に削減されています。
本記事では、Bevy 0.16のレンダリンググラフ再設計の技術的詳細と、従来アーキテクチャとの比較、実装パターン、既存プロジェクトのマイグレーション手順を実装コード付きで解説します。
Bevy 0.16のレンダリンググラフ再設計:何が変わったのか
従来の課題:Extract-Prepare-Renderの暗黙的結合
Bevy 0.15以前のレンダリングパイプラインでは、Extract(CPUからのデータ抽出)、Prepare(GPUバッファ準備)、Render(描画コマンド生成)の3フェーズが、単一のレンダリンググラフノード内で暗黙的に実行されていました。
この設計には以下の問題がありました:
- 過剰な同期ポイント:各ノードがすべてのフェーズを含むため、GPU待機が頻発
- 並列化の制約:依存関係のない処理も順次実行される
- メモリ局所性の低下:データ変換処理が分散し、キャッシュミスが増加
新アーキテクチャ:明示的なフェーズ分離とバッチング
Bevy 0.16では、レンダリンググラフが以下のように再設計されました:
// Bevy 0.16の新しいレンダリングフェーズ定義
pub enum RenderPhase {
Extract, // ECSワールドからレンダリングデータを抽出
Prepare, // GPUバッファの確保・更新
Queue, // 描画コマンドのキューイング(新規追加)
Render, // 実際のGPUコマンド発行
}
Queue フェーズの追加が最大の変更点です。このフェーズでは、描画コマンドのソート・バッチング・カリングを一括処理し、Renderフェーズでは純粋にGPUコマンドの発行のみを行います。
以下のダイアグラムは、Bevy 0.16の新しいレンダリングパイプラインフローを示しています:
graph TD
A["ECSワールド(メインスレッド)"] -->|並列Extract| B["ExtractedData"]
B -->|並列Prepare| C["GPUバッファ"]
C -->|Queue(バッチング)| D["SortedRenderCommands"]
D -->|Render| E["GPUコマンドバッファ"]
E --> F["GPU実行"]
style A fill:#e1f5ff
style C fill:#fff4e1
style E fill:#ffe1e1
style D fill:#e1ffe1
このフェーズ分離により、各段階が独立して並列実行可能になり、CPUとGPUのパイプライン処理が最適化されます。
パフォーマンス改善の数値
公式ベンチマーク(many_cubes テスト:10万メッシュ描画)での計測結果:
| 項目 | Bevy 0.15 | Bevy 0.16 | 改善率 |
|---|---|---|---|
| フレーム生成時間 | 8.3ms | 4.8ms | 42%削減 |
| Extract時間 | 2.1ms | 1.2ms | 43%削減 |
| Queue時間 | - | 0.9ms | (新規) |
| Render時間 | 4.7ms | 2.1ms | 55%削減 |
特にRenderフェーズの改善が顕著で、これはバッチングとソートを事前に完了させることで、GPUコマンド発行のオーバーヘッドが劇的に減少したことを示しています。
RenderGraphノードの新しい実装パターン
ExtractNodeの実装:並列データ抽出
Bevy 0.16では、Extractノードが明示的に定義され、並列実行が保証されます:
use bevy::prelude::*;
use bevy::render::{Extract, RenderApp};
use bevy::render::extract_component::ExtractComponent;
// 抽出対象のコンポーネント
#[derive(Component, Clone)]
pub struct CustomMesh {
pub vertex_count: u32,
pub material_id: u32,
}
// ExtractComponentトレイトを実装
impl ExtractComponent for CustomMesh {
type Query = &'static Self;
type Filter = With<Visibility>; // 可視オブジェクトのみ抽出
type Out = Self;
fn extract_component(item: bevy::ecs::query::QueryItem<Self::Query>) -> Option<Self::Out> {
Some(item.clone())
}
}
// プラグインでExtractシステムを登録
pub struct CustomRenderPlugin;
impl Plugin for CustomRenderPlugin {
fn build(&self, app: &mut App) {
app.sub_app_mut(RenderApp)
.add_systems(ExtractSchedule, extract_custom_meshes);
}
}
fn extract_custom_meshes(
mut commands: Commands,
query: Extract<Query<(Entity, &CustomMesh), With<Visibility>>>,
) {
for (entity, mesh) in query.iter() {
commands.get_or_spawn(entity).insert(mesh.clone());
}
}
重要な変更点:
Extract<Query<...>>型により、メインワールドへの読み取り専用アクセスが明示的に- 可視性判定を
Filterで事前に行うことで、不要なデータ抽出を回避 - 並列実行が保証されるため、
Par Iterの手動実装が不要に
PrepareNodeの実装:GPUバッファの効率的な更新
Prepareフェーズでは、抽出されたデータをGPUバッファに変換します:
use bevy::render::render_resource::{Buffer, BufferInitDescriptor, BufferUsages};
use bevy::render::renderer::RenderDevice;
#[derive(Resource)]
pub struct CustomMeshBuffer {
pub buffer: Buffer,
pub capacity: usize,
}
fn prepare_custom_meshes(
mut mesh_buffer: ResMut<CustomMeshBuffer>,
render_device: Res<RenderDevice>,
query: Query<&CustomMesh>,
) {
let mesh_count = query.iter().count();
// バッファ容量が不足している場合のみ再確保
if mesh_count > mesh_buffer.capacity {
let new_capacity = (mesh_count * 3 / 2).max(256); // 1.5倍の余裕を確保
mesh_buffer.buffer = render_device.create_buffer(&BufferInitDescriptor {
label: Some("custom_mesh_buffer"),
contents: &vec![0u8; new_capacity * std::mem::size_of::<CustomMesh>()],
usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
});
mesh_buffer.capacity = new_capacity;
}
// バッファへのデータ書き込み(Queueフェーズでソート済みデータを使用)
let mesh_data: Vec<CustomMesh> = query.iter().cloned().collect();
render_device.queue().write_buffer(
&mesh_buffer.buffer,
0,
bytemuck::cast_slice(&mesh_data),
);
}
この実装により、バッファの再確保頻度が減少し、メモリアロケーションのオーバーヘッドが削減されます。
Queueノードの実装:描画コマンドのバッチング
新規追加されたQueueフェーズでは、描画コマンドのソート・バッチング・視錐台カリングを実行します:
use bevy::render::render_phase::{PhaseItem, RenderCommand, RenderCommandResult};
use bevy::render::render_resource::PipelineCache;
pub struct CustomPhaseItem {
pub entity: Entity,
pub draw_key: u64, // マテリアルID + 深度でソート
pub batch_range: Range<u32>,
}
impl PhaseItem for CustomPhaseItem {
type SortKey = u64;
fn sort_key(&self) -> Self::SortKey {
self.draw_key
}
fn draw_function(&self) -> DrawFunctionId {
CUSTOM_DRAW_FUNCTION
}
}
fn queue_custom_meshes(
mut views: Query<&mut RenderPhase<CustomPhaseItem>>,
meshes: Query<(Entity, &CustomMesh, &GlobalTransform)>,
pipeline_cache: Res<PipelineCache>,
) {
for mut phase in views.iter_mut() {
for (entity, mesh, transform) in meshes.iter() {
// 視錐台カリング(Queueフェーズで実行)
if !phase.view_frustum.intersects_sphere(&transform.translation(), 1.0) {
continue;
}
// ソートキーの生成(マテリアル優先、次に深度)
let depth = (transform.translation() - phase.view_transform.translation()).length();
let sort_key = (mesh.material_id as u64) << 32 | (depth as u32) as u64;
phase.add(CustomPhaseItem {
entity,
draw_key: sort_key,
batch_range: 0..1,
});
}
}
}
Queueフェーズの利点:
- カリング判定をRenderフェーズから分離することで、GPUコマンド発行時の分岐を削減
- マテリアルIDでソートすることで、GPU状態変更を最小化
- バッチ範囲を事前計算し、Renderフェーズでは単純なループ処理に
レンダリンググラフの同期とパイプライン処理
新しい依存関係定義システム
Bevy 0.16では、レンダリンググラフノード間の依存関係が、より細かい粒度で定義できるようになりました:
use bevy::render::render_graph::{RenderGraph, RenderLabel, NodeRunError};
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
pub enum CustomRenderLabel {
ExtractMeshes,
PrepareMeshes,
QueueMeshes,
RenderMeshes,
}
fn setup_render_graph(render_app: &mut App) {
let mut graph = render_app.world.resource_mut::<RenderGraph>();
// ノードの追加
graph.add_node(CustomRenderLabel::ExtractMeshes, ExtractMeshesNode);
graph.add_node(CustomRenderLabel::PrepareMeshes, PrepareMeshesNode);
graph.add_node(CustomRenderLabel::QueueMeshes, QueueMeshesNode);
graph.add_node(CustomRenderLabel::RenderMeshes, RenderMeshesNode);
// 依存関係の定義(並列実行可能な部分を明示)
graph.add_node_edge(CustomRenderLabel::ExtractMeshes, CustomRenderLabel::PrepareMeshes);
graph.add_node_edge(CustomRenderLabel::PrepareMeshes, CustomRenderLabel::QueueMeshes);
graph.add_node_edge(CustomRenderLabel::QueueMeshes, CustomRenderLabel::RenderMeshes);
}
以下のシーケンス図は、Bevy 0.16でのフレーム処理タイムラインを示しています:
sequenceDiagram
participant CPU as CPUメインスレッド
participant Extract as Extractスレッド群
participant Prepare as Prepareスレッド
participant Queue as Queueスレッド
participant GPU as GPUコマンド発行
CPU->>Extract: フレームN: Extract開始
activate Extract
Extract-->>Prepare: 抽出データ転送
deactivate Extract
CPU->>Extract: フレームN+1: Extract開始(並列実行)
activate Extract
activate Prepare
Prepare->>Queue: バッファ準備完了
deactivate Prepare
activate Queue
Queue->>GPU: ソート済みコマンド
deactivate Queue
activate GPU
GPU-->>CPU: フレームN完了
deactivate GPU
Extract-->>Prepare: フレームN+1データ転送
deactivate Extract
このパイプライン処理により、CPUとGPUの待機時間が最小化され、スループットが向上します。
Bevy 0.15からのマイグレーション手順
変更が必要なコードパターン
Bevy 0.15で以下のようなカスタムレンダリングを実装していた場合、修正が必要です:
// Bevy 0.15の古いパターン(非推奨)
fn old_render_system(
mut commands: Commands,
query: Query<&MyComponent>,
render_device: Res<RenderDevice>,
) {
// Extract, Prepare, Renderが混在
for component in query.iter() {
let buffer = render_device.create_buffer(...); // Prepare処理
commands.spawn().insert(MyRenderData { buffer }); // Extract処理
}
}
マイグレーション後のコード
フェーズごとに分離:
// Bevy 0.16の推奨パターン
// 1. Extractシステム
fn extract_my_component(
mut commands: Commands,
query: Extract<Query<(Entity, &MyComponent)>>,
) {
for (entity, component) in query.iter() {
commands.get_or_spawn(entity).insert(MyExtractedData {
value: component.value,
});
}
}
// 2. Prepareシステム
fn prepare_my_buffers(
query: Query<&MyExtractedData>,
render_device: Res<RenderDevice>,
mut buffers: ResMut<MyBufferStorage>,
) {
for data in query.iter() {
let buffer = render_device.create_buffer_with_data(&data.value);
buffers.insert(data.entity, buffer);
}
}
// 3. Queueシステム
fn queue_my_draws(
mut phase: ResMut<RenderPhase<MyPhaseItem>>,
query: Query<(Entity, &MyExtractedData)>,
) {
for (entity, data) in query.iter() {
phase.add(MyPhaseItem {
entity,
sort_key: data.priority,
});
}
}
// 4. Renderコマンド実装
struct DrawMyMesh;
impl RenderCommand<MyPhaseItem> for DrawMyMesh {
fn render<'w>(
item: &MyPhaseItem,
view: ROQueryItem<'w, ViewQuery>,
entity: ROQueryItem<'w, EntityQuery>,
pass: &mut TrackedRenderPass<'w>,
) -> RenderCommandResult {
pass.draw(0..entity.vertex_count, 0..1);
RenderCommandResult::Success
}
}
プラグイン登録の変更
impl Plugin for MyRenderPlugin {
fn build(&self, app: &mut App) {
app.sub_app_mut(RenderApp)
.add_systems(ExtractSchedule, extract_my_component)
.add_systems(Prepare, prepare_my_buffers)
.add_systems(Queue, queue_my_draws);
}
}
この分離により、各フェーズが並列実行可能になり、パフォーマンスが向上します。
まとめ
Bevy 0.16のレンダリンググラフ再設計は、以下の成果をもたらしました:
- 明示的なフェーズ分離:Extract-Prepare-Queue-Renderの4段階に分離し、各フェーズの役割が明確化
- パイプライン処理の最適化:CPUとGPUの並列実行が促進され、待機時間が最大42%削減
- Queueフェーズの追加:バッチング・ソート・カリングを事前処理することで、Renderフェーズの負荷を大幅に軽減
- 並列実行の保証:依存関係が明示的に定義され、並列化可能な処理が自動的に並列実行される
- メモリ局所性の向上:フェーズごとにデータ変換が完結し、キャッシュヒット率が改善
既存プロジェクトのマイグレーションには多少の手間がかかりますが、フェーズ分離パターンに従うことで、コードの保守性とパフォーマンスの両面で大きな利益が得られます。特に大規模なシーン(10万メッシュ以上)を扱うプロジェクトでは、この改善効果が顕著に現れるでしょう。