メインコンテンツへスキップ
Tech Playground
ゲーム開発

Bevy 0.19 Asset Streaming 大規模オープンワールド動的ロード最適化【2026年5月新API】

Bevy 0.19で刷新されたAsset Streaming APIを活用し、大規模オープンワールドゲームの動的ロード戦略を実装。メモリ管理とロード遅延の最適化テクニックを詳解します。

約12分で読めます

Bevy 0.19が2026年5月にリリースされ、Asset Streaming APIが大幅に刷新されました。この新しいAPIは、大規模オープンワールドゲーム開発における最大の課題である「必要なアセットだけをメモリに保持し、不要なアセットを効率的にアンロードする」動的ロード戦略の実装を劇的に簡素化します。

従来のBevy 0.18までのAsset Serverは、一度ロードしたアセットをメモリに保持し続ける設計でした。これは小規模なゲームでは問題になりませんが、数GBのアセットを持つオープンワールドゲームでは、メモリ不足やロード時間の増加が深刻な問題となります。

Bevy 0.19の新しいAsset Streaming APIは、以下の課題を解決します。

  • プレイヤーの位置に基づくアセットの自動ロード/アンロード
  • メモリ予算内でのアセット優先度管理
  • バックグラウンドでの非同期ロードによるフレームレート維持
  • アセット依存関係の自動解決と段階的ロード

本記事では、Bevy 0.19の公式ドキュメントとGitHubリポジトリのリリースノート(2026年5月14日公開)を基に、新しいAsset Streaming APIの実装パターンと最適化テクニックを詳細に解説します。

Bevy 0.19 Asset Streaming APIの新機能

Bevy 0.19で導入されたAsset Streaming APIの中核は、AssetStreamingPluginStreamingAssetLoaderの2つのコンポーネントです。

AssetStreamingPluginは、アセットのロード優先度とメモリ予算を管理します。プレイヤーの位置や視線方向に基づいて、どのアセットを先にロードするかを動的に決定します。

StreamingAssetLoaderは、非同期ロード処理を担当します。従来のAsset Serverとは異なり、ロード中のアセットの状態を細かく監視し、必要に応じて中断・再開が可能です。

以下は、Bevy 0.19の新しいAPIを使った基本的な実装例です。

use bevy::prelude::*;
use bevy::asset::streaming::{AssetStreamingPlugin, StreamingConfig, PriorityConfig};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(AssetStreamingPlugin {
            config: StreamingConfig {
                memory_budget_mb: 2048, // メモリ予算2GB
                load_distance: 500.0,    // ロード開始距離500m
                unload_distance: 800.0,  // アンロード距離800m
                priority: PriorityConfig::DistanceBased, // 距離ベース優先度
            },
        })
        .add_systems(Startup, setup_world)
        .add_systems(Update, update_streaming_positions)
        .run();
}

#[derive(Component)]
struct StreamingOrigin;

fn setup_world(mut commands: Commands) {
    // プレイヤーをストリーミングの基準点として登録
    commands.spawn((
        Camera3dBundle::default(),
        StreamingOrigin,
    ));
}

fn update_streaming_positions(
    query: Query<&Transform, With<StreamingOrigin>>,
    mut streaming: ResMut<AssetStreamingState>,
) {
    if let Ok(transform) = query.get_single() {
        streaming.update_origin(transform.translation);
    }
}

このコードでは、AssetStreamingPluginにメモリ予算2GBを設定し、プレイヤーから500m以内のアセットをロード、800m以上離れたアセットをアンロードするように構成しています。

Bevy 0.19の公式ドキュメントによると、この設定により、従来の静的ロード方式と比較してメモリ使用量を平均60%削減できることが実証されています。

距離ベースアセットストリーミングの実装

大規模オープンワールドゲームでは、プレイヤーの位置を中心とした同心円状のゾーンに分けてアセットをロードする「距離ベースストリーミング」が効果的です。

Bevy 0.19では、StreamingZoneコンポーネントを使用してこのパターンを実装できます。

以下は、3つのロード優先度ゾーンを定義した実装例です。

use bevy::prelude::*;
use bevy::asset::streaming::{StreamingZone, ZonePriority};

#[derive(Component)]
struct WorldChunk {
    position: Vec3,
    assets: Vec<Handle<Mesh>>,
    zone: StreamingZone,
}

fn setup_world_chunks(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    // ワールドを100x100のチャンクに分割
    for x in -50..50 {
        for z in -50..50 {
            let position = Vec3::new(x as f32 * 100.0, 0.0, z as f32 * 100.0);
            
            let zone = if position.length() < 500.0 {
                StreamingZone::new(ZonePriority::High, 1.0) // 高優先度
            } else if position.length() < 1000.0 {
                StreamingZone::new(ZonePriority::Medium, 0.5) // 中優先度
            } else {
                StreamingZone::new(ZonePriority::Low, 0.2) // 低優先度
            };
            
            commands.spawn(WorldChunk {
                position,
                assets: vec![
                    asset_server.load_streaming(format!("terrain/chunk_{}_{}.glb", x, z)),
                    asset_server.load_streaming(format!("textures/chunk_{}_{}.png", x, z)),
                ],
                zone,
            });
        }
    }
}

fn update_chunk_priorities(
    player_query: Query<&Transform, With<StreamingOrigin>>,
    mut chunk_query: Query<(&Transform, &mut StreamingZone), With<WorldChunk>>,
) {
    if let Ok(player_transform) = player_query.get_single() {
        for (chunk_transform, mut zone) in chunk_query.iter_mut() {
            let distance = player_transform.translation.distance(chunk_transform.translation);
            
            // 距離に応じて優先度を動的に更新
            let new_priority = if distance < 500.0 {
                ZonePriority::High
            } else if distance < 1000.0 {
                ZonePriority::Medium
            } else {
                ZonePriority::Low
            };
            
            zone.update_priority(new_priority);
        }
    }
}

この実装では、プレイヤーから500m以内のチャンクを高優先度、500m~1000mを中優先度、1000m以上を低優先度として管理しています。

プレイヤーが移動すると、各チャンクの優先度が動的に更新され、Asset Streaming APIが自動的にロード/アンロードを調整します。

Bevy公式のベンチマークによると、この手法により、10,000チャンクのオープンワールドマップで平均ロード時間を従来の静的ロード(12秒)から動的ロード(0.8秒)に短縮できることが報告されています(2026年5月14日のリリースノート参照)。

以下のダイアグラムは、距離ベースストリーミングの処理フローを示しています。

flowchart TD
    A["プレイヤー移動検出"] --> B["全チャンクの距離計算"]
    B --> C{"距離 < 500m?"}
    C -->|Yes| D["高優先度設定"]
    C -->|No| E{"距離 < 1000m?"}
    E -->|Yes| F["中優先度設定"]
    E -->|No| G["低優先度設定"]
    D --> H["ストリーミングキューに追加"]
    F --> H
    G --> H
    H --> I["メモリ予算チェック"]
    I -->|予算内| J["非同期ロード開始"]
    I -->|予算超過| K["低優先度アセットをアンロード"]
    K --> J
    J --> L["ロード完了後、エンティティにアタッチ"]

このフローにより、メモリ予算を超えないようにしながら、プレイヤーの周囲のアセットを優先的にロードできます。

メモリ予算管理と自動アンロード戦略

大規模オープンワールドゲームでは、メモリ予算内でアセットを管理することが重要です。Bevy 0.19のMemoryBudgetManagerは、アセットのメモリ使用量を監視し、予算を超えた場合に自動的に低優先度アセットをアンロードします。

以下は、メモリ予算管理の詳細な実装例です。

use bevy::prelude::*;
use bevy::asset::streaming::{MemoryBudgetManager, AssetMemoryStats};

#[derive(Resource)]
struct AssetMemoryBudget {
    total_mb: usize,
    textures_mb: usize,
    meshes_mb: usize,
    audio_mb: usize,
}

fn setup_memory_budget(mut commands: Commands) {
    commands.insert_resource(AssetMemoryBudget {
        total_mb: 2048,    // 総予算2GB
        textures_mb: 1024, // テクスチャ1GB
        meshes_mb: 768,    // メッシュ768MB
        audio_mb: 256,     // オーディオ256MB
    });
}

fn monitor_memory_usage(
    budget: Res<AssetMemoryBudget>,
    memory_stats: Res<AssetMemoryStats>,
    mut streaming: ResMut<AssetStreamingState>,
) {
    // メモリ使用率を計算
    let texture_usage_percent = (memory_stats.textures_mb as f32 / budget.textures_mb as f32) * 100.0;
    let mesh_usage_percent = (memory_stats.meshes_mb as f32 / budget.meshes_mb as f32) * 100.0;
    
    // 80%を超えたら低優先度アセットをアンロード
    if texture_usage_percent > 80.0 {
        streaming.unload_lowest_priority_textures(256); // 256MBアンロード
        info!("Texture memory exceeded 80%, unloading low priority textures");
    }
    
    if mesh_usage_percent > 80.0 {
        streaming.unload_lowest_priority_meshes(128); // 128MBアンロード
        info!("Mesh memory exceeded 80%, unloading low priority meshes");
    }
    
    // デバッグ用メモリ統計を表示
    debug!(
        "Memory usage - Textures: {:.1}% ({}/{}MB), Meshes: {:.1}% ({}/{}MB)",
        texture_usage_percent, memory_stats.textures_mb, budget.textures_mb,
        mesh_usage_percent, memory_stats.meshes_mb, budget.meshes_mb
    );
}

この実装では、テクスチャとメッシュのメモリ使用率を個別に監視し、80%を超えた場合に低優先度アセットを自動的にアンロードします。

Bevy 0.19のドキュメントによると、この自動アンロード戦略により、メモリ使用量のピークを平均40%削減できることが確認されています。

さらに、アセットの参照カウントを追跡することで、使用されていないアセットを優先的にアンロードする「LRU(Least Recently Used)キャッシュ戦略」を実装できます。

use bevy::prelude::*;
use bevy::asset::streaming::LruAssetCache;
use std::time::Duration;

fn setup_lru_cache(mut commands: Commands) {
    commands.insert_resource(LruAssetCache::new(
        Duration::from_secs(300), // 5分間未使用のアセットをアンロード
        1024,                     // 最大1024アセットをキャッシュ
    ));
}

fn update_asset_usage(
    visible_meshes: Query<&Handle<Mesh>, With<Visibility>>,
    mut lru_cache: ResMut<LruAssetCache>,
) {
    // 現在表示されているメッシュをキャッシュに登録
    for mesh_handle in visible_meshes.iter() {
        lru_cache.mark_used(mesh_handle.id());
    }
    
    // 未使用アセットをアンロード
    lru_cache.evict_unused();
}

このLRUキャッシュ戦略により、プレイヤーが頻繁に訪れるエリアのアセットはメモリに保持され、長時間使用されていないアセットは自動的にアンロードされます。

以下のダイアグラムは、メモリ予算管理とLRUキャッシュの連携フローを示しています。

stateDiagram-v2
    [*] --> Loaded: アセットロード完了
    Loaded --> InUse: エンティティにアタッチ
    InUse --> Loaded: エンティティから削除
    Loaded --> Cached: 5分間未使用
    Cached --> InUse: 再利用
    Cached --> Unloaded: メモリ予算超過
    InUse --> Unloaded: 強制アンロード(予算超過)
    Unloaded --> [*]
    
    note right of Cached
        LRUキャッシュで管理
        最大1024アセット
    end note
    
    note right of Unloaded
        低優先度から順に
        アンロード
    end note

この状態遷移により、メモリ使用量を予算内に維持しながら、頻繁に使用されるアセットは高速にアクセスできるようになります。

非同期ロードとフレームレート維持

大規模アセットのロード中にフレームレートが低下する問題は、オープンワールドゲーム開発における最大の課題の一つです。Bevy 0.19のAsyncAssetLoaderは、バックグラウンドスレッドで非同期ロードを実行し、メインスレッドのフレームレートに影響を与えません。

以下は、非同期ロードの詳細な実装例です。

use bevy::prelude::*;
use bevy::asset::streaming::{AsyncAssetLoader, LoadPriority, LoadProgress};

#[derive(Component)]
struct AsyncLoadingChunk {
    position: Vec3,
    load_handle: Handle<Scene>,
    progress: LoadProgress,
}

fn start_async_loading(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    player_query: Query<&Transform, With<StreamingOrigin>>,
) {
    if let Ok(player_transform) = player_query.get_single() {
        // プレイヤー周辺のチャンクを非同期ロード
        for x in -5..5 {
            for z in -5..5 {
                let chunk_position = Vec3::new(
                    player_transform.translation.x + (x as f32 * 100.0),
                    0.0,
                    player_transform.translation.z + (z as f32 * 100.0),
                );
                
                let distance = player_transform.translation.distance(chunk_position);
                let priority = if distance < 200.0 {
                    LoadPriority::Critical // 200m以内は最優先
                } else if distance < 500.0 {
                    LoadPriority::High
                } else {
                    LoadPriority::Normal
                };
                
                let load_handle = asset_server.load_async_with_priority(
                    format!("chunks/chunk_{}_{}.glb", x, z),
                    priority,
                );
                
                commands.spawn(AsyncLoadingChunk {
                    position: chunk_position,
                    load_handle,
                    progress: LoadProgress::default(),
                });
            }
        }
    }
}

fn track_loading_progress(
    mut chunk_query: Query<(&mut AsyncLoadingChunk, &Handle<Scene>)>,
    asset_server: Res<AssetServer>,
    mut commands: Commands,
) {
    for (mut chunk, scene_handle) in chunk_query.iter_mut() {
        // ロード進捗を取得
        if let Some(progress) = asset_server.get_load_progress(&chunk.load_handle) {
            chunk.progress = progress;
            
            // ロード完了時にシーンをスポーン
            if progress.is_complete() {
                commands.spawn(SceneBundle {
                    scene: scene_handle.clone(),
                    transform: Transform::from_translation(chunk.position),
                    ..default()
                });
                
                info!("Chunk at {:?} loaded successfully", chunk.position);
            }
        }
    }
}

この実装では、LoadPriorityを使用してロード優先度を制御し、プレイヤーに近いチャンクを優先的にロードしています。

Bevy 0.19のベンチマーク結果によると、この非同期ロード方式により、100MBのアセットをロード中でもフレームレートの低下を平均5%未満に抑えることができます(従来の同期ロードでは平均40%低下)。

さらに、ロード進捗をUIに表示することで、プレイヤーに待機時間を明示できます。

use bevy::prelude::*;

#[derive(Component)]
struct LoadingUI;

fn update_loading_ui(
    chunk_query: Query<&AsyncLoadingChunk>,
    mut text_query: Query<&mut Text, With<LoadingUI>>,
) {
    let total_chunks = chunk_query.iter().count();
    let loaded_chunks = chunk_query
        .iter()
        .filter(|chunk| chunk.progress.is_complete())
        .count();
    
    let progress_percent = (loaded_chunks as f32 / total_chunks as f32) * 100.0;
    
    for mut text in text_query.iter_mut() {
        text.sections[0].value = format!(
            "Loading: {:.0}% ({}/{})",
            progress_percent, loaded_chunks, total_chunks
        );
    }
}

このUIシステムにより、プレイヤーはロード進捗を視覚的に確認でき、待機時間のストレスを軽減できます。

以下のシーケンスダイアグラムは、非同期ロードの処理フローを示しています。

sequenceDiagram
    participant Player
    participant MainThread
    participant AssetServer
    participant BackgroundThread
    participant GPU
    
    Player->>MainThread: 移動(チャンク境界通過)
    MainThread->>AssetServer: load_async_with_priority()
    AssetServer->>BackgroundThread: ロードタスク追加
    
    loop ロード中
        BackgroundThread->>BackgroundThread: ファイル読み込み
        BackgroundThread->>BackgroundThread: デシリアライズ
        BackgroundThread->>AssetServer: 進捗更新
        AssetServer->>MainThread: LoadProgress通知
        MainThread->>Player: UIに進捗表示
    end
    
    BackgroundThread->>AssetServer: ロード完了
    AssetServer->>MainThread: アセットハンドル提供
    MainThread->>GPU: メッシュ/テクスチャ転送
    GPU->>MainThread: 転送完了
    MainThread->>Player: チャンク表示

このフローにより、ファイル読み込みとデシリアライズはバックグラウンドスレッドで実行され、メインスレッドはフレームレートを維持したまま進捗を監視できます。

アセット依存関係の自動解決と段階的ロード

オープンワールドゲームのアセットは、多くの場合、他のアセットに依存しています。例えば、3Dモデル(.glb)はテクスチャ(.png)やマテリアル(.mat)に依存し、これらをすべて正しい順序でロードする必要があります。

Bevy 0.19のDependencyResolverは、アセット間の依存関係を自動的に解析し、正しい順序でロードします。

以下は、依存関係を持つアセットの実装例です。

use bevy::prelude::*;
use bevy::asset::streaming::{DependencyResolver, AssetDependency};

#[derive(Asset, TypePath)]
struct TerrainChunk {
    mesh: Handle<Mesh>,
    material: Handle<StandardMaterial>,
    collision: Handle<CollisionMesh>,
}

impl AssetDependency for TerrainChunk {
    fn dependencies(&self) -> Vec<UntypedHandle> {
        vec![
            self.mesh.clone().untyped(),
            self.material.clone().untyped(),
            self.collision.clone().untyped(),
        ]
    }
}

fn load_terrain_chunk_with_dependencies(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    let chunk_handle = asset_server.load_with_dependencies::<TerrainChunk>(
        "terrain/chunk_0_0.terrain",
    );
    
    commands.spawn(AsyncLoadingChunk {
        position: Vec3::ZERO,
        load_handle: chunk_handle,
        progress: LoadProgress::default(),
    });
}

fn wait_for_dependencies(
    chunk_query: Query<(&AsyncLoadingChunk, &Handle<TerrainChunk>)>,
    asset_server: Res<AssetServer>,
    chunks: Res<Assets<TerrainChunk>>,
    mut commands: Commands,
) {
    for (chunk_data, chunk_handle) in chunk_query.iter() {
        // 依存関係がすべてロード完了するまで待機
        if asset_server.are_dependencies_loaded(chunk_handle) {
            if let Some(terrain_chunk) = chunks.get(chunk_handle) {
                commands.spawn(PbrBundle {
                    mesh: terrain_chunk.mesh.clone(),
                    material: terrain_chunk.material.clone(),
                    transform: Transform::from_translation(chunk_data.position),
                    ..default()
                });
                
                info!("Terrain chunk with all dependencies loaded at {:?}", chunk_data.position);
            }
        }
    }
}

この実装では、AssetDependencyトレイトを実装することで、TerrainChunkが依存する3つのアセット(メッシュ、マテリアル、コリジョンメッシュ)を明示的に宣言しています。

DependencyResolverは、これらの依存関係を自動的に解析し、以下の順序でロードします。

  1. 依存元のアセット(TerrainChunk)のメタデータ読み込み
  2. 依存先のアセット(Mesh、Material、CollisionMesh)の並列ロード
  3. すべての依存先ロード完了後、依存元のアセット構築
  4. 最終的なアセットをエンティティにアタッチ

Bevy 0.19のドキュメントによると、この依存関係自動解決により、手動で依存順序を管理する場合と比較して、実装コード量を平均50%削減できることが報告されています。

さらに、段階的ロード(Progressive Loading)を使用することで、低解像度版を先にロードし、後から高解像度版に置き換えることができます。

use bevy::asset::streaming::ProgressiveLoader;

#[derive(Component)]
struct ProgressiveTexture {
    low_res: Handle<Image>,
    high_res: Handle<Image>,
    current_state: TextureState,
}

#[derive(PartialEq)]
enum TextureState {
    LowRes,
    HighRes,
}

fn load_progressive_textures(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    let low_res = asset_server.load_with_priority(
        "textures/terrain_low.png",
        LoadPriority::Critical,
    );
    
    let high_res = asset_server.load_with_priority(
        "textures/terrain_high.png",
        LoadPriority::Normal,
    );
    
    commands.spawn(ProgressiveTexture {
        low_res,
        high_res,
        current_state: TextureState::LowRes,
    });
}

fn upgrade_textures_when_ready(
    mut texture_query: Query<(&mut ProgressiveTexture, &mut Handle<StandardMaterial>)>,
    asset_server: Res<AssetServer>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    for (mut progressive, material_handle) in texture_query.iter_mut() {
        // 低解像度版がロード完了したら即座に適用
        if progressive.current_state == TextureState::LowRes {
            if asset_server.is_loaded(&progressive.low_res) {
                if let Some(material) = materials.get_mut(material_handle) {
                    material.base_color_texture = Some(progressive.low_res.clone());
                }
            }
        }
        
        // 高解像度版がロード完了したら置き換え
        if asset_server.is_loaded(&progressive.high_res) {
            if let Some(material) = materials.get_mut(material_handle) {
                material.base_color_texture = Some(progressive.high_res.clone());
                progressive.current_state = TextureState::HighRes;
            }
        }
    }
}

この段階的ロード戦略により、プレイヤーは低解像度版を即座に確認でき、バックグラウンドで高解像度版がロードされるのを待つ間もゲームをプレイできます。

以下のダイアグラムは、依存関係解決と段階的ロードの処理フローを示しています。

graph TD
    A["TerrainChunkロード開始"] --> B["依存関係解析"]
    B --> C["Mesh(低解像度)"]
    B --> D["Material(低解像度)"]
    B --> E["CollisionMesh"]
    
    C --> F["低解像度版即座に表示"]
    D --> F
    E --> F
    
    F --> G["高解像度Meshロード"]
    F --> H["高解像度Materialロード"]
    
    G --> I["高解像度版に置き換え"]
    H --> I
    
    I --> J["最終品質で表示"]
    
    style F fill:#90EE90
    style I fill:#FFD700
    style J fill:#00CED1

このフローにより、プレイヤーは待機時間を最小化しながら、最終的には最高品質のアセットでゲームを楽しめます。

まとめ

Bevy 0.19のAsset Streaming APIは、大規模オープンワールドゲーム開発における動的ロードの課題を劇的に改善しました。本記事で紹介した実装パターンの要点は以下の通りです。

  • 距離ベースストリーミング: プレイヤーの位置に基づいてアセットの優先度を動的に更新し、必要なアセットだけをロード
  • メモリ予算管理: メモリ使用率を監視し、80%を超えた場合に低優先度アセットを自動アンロード。LRUキャッシュ戦略により、頻繁に使用されるアセットはメモリに保持
  • 非同期ロード: バックグラウンドスレッドで非同期ロードを実行し、フレームレート低下を平均5%未満に抑制
  • 依存関係自動解決: アセット間の依存関係を自動的に解析し、正しい順序でロード。段階的ロードにより、低解像度版を先に表示して待機時間を短縮

これらのテクニックを組み合わせることで、数GBのアセットを持つオープンワールドゲームでも、メモリ使用量を60%削減し、ロード時間を12秒から0.8秒に短縮できます。

Bevy 0.19は2026年5月14日にリリースされたばかりであり、今後さらなる最適化手法やベストプラクティスがコミュニティから報告されることが期待されます。公式ドキュメントとGitHubリポジトリの最新情報を継続的にチェックすることをお勧めします。

参考リンク

#Rust #Bevy #Asset Streaming #オープンワールド #動的ロード
シェア: