Ccmmutty logo
Commutty IT
0 pv19 min read

【備忘録】Bevy + Lightyear におけるパフォーマンス最適化

https://cdn.magicode.io/media/notebox/Gemini_Generated_Image_ktifkpktifkpktif.png

【備忘録】Bevy + Lightyear におけるパフォーマンス最適化

【この記事の内容】
Bevy 0.18 と Lightyear 0.26 を使用した一般的なリアルタイムマルチプレイヤーゲームにおける、パフォーマンス最適化のアプローチをまとめ

TL;DR

  1. アーキテクチャ概要
  2. ネットワーク最適化
  3. サーバー側の計算最適化
  4. クライアント側のレンダリング最適化
  5. クライアント側の補間・予測
  6. フレームレート安定化
  7. 教訓: InputPlugin の落とし穴と直接メッセージ送信

1. アーキテクチャ概要

┌─────────────┐         WebSocket          ┌─────────────┐
│   Client     │ ◄──────────────────────►  │   Server     │
│  (Bevy +     │   PlayerInput (30Hz)       │  (Bevy +     │
│   Rendering) │   ServerPosition (20Hz)    │   Lightyear)  │
└─────────────┘                             └─────────────┘
前提条件
  • サーバー権威型: ゲームロジックはすべてサーバーで実行
  • サーバー: 30 TPS(FixedUpdate)でシミュレーション、20Hz でクライアントにレプリケーション
  • クライアント: 60fps でレンダリング、スナップショット補間 + 自セル予測で滑らかな表示

2. ネットワーク最適化

2.1 ServerPosition の分離 — Transform を直接レプリケーションしない

問題: Lightyear がコンポーネントをレプリケーションすると、クライアント側の値を直接上書きする。Transform をレプリケーションすると、クライアント側で行っている補間やスムージングが毎フレーム破壊される。
解決策: ServerPosition という専用コンポーネントを導入し、Transform ではなくこれをレプリケーションする。
// 共有コンポーネント — レプリケーション対象
#[derive(Component, Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct ServerPosition {
    pub x: f32,
    pub y: f32,
    pub z: f32,
}
// サーバー: 毎ティック Transform → ServerPosition にコピー
fn sync_server_position(
    mut query: Query<(&Transform, &mut ServerPosition), Changed<Transform>>
) {
    for (transform, mut server_pos) in query.iter_mut() {
        let t = transform.translation;
        server_pos.x = t.x;
        server_pos.y = t.y;
        server_pos.z = t.z;
    }
}
// クライアント: ServerPosition の変更を検知 → 補間バッファに格納
// Transform はクライアントが完全に制御(Lightyear による上書きなし)
pub fn capture_server_position(
    mut query: Query<(&ServerPosition, &mut ServerTarget), Changed<ServerPosition>>,
    time: Res<Time>,
) { ... }
効果: クライアント側で自由に補間・予測を行える。Lightyear のレプリケーションと共存可能。

2.2 レプリケーションバッチング(50ms / 20Hz)

サーバーの ReplicationSender を 50ms バッチに設定:
ReplicationSender::new(
    Duration::from_millis(50), // 50msバッチ(20Hz)
    SendUpdatesMode::SinceLastAck,
    false,
)
効果: サーバーは 30 TPS で計算するが、レプリケーションは 20Hz に間引かれる。毎ティック送信する場合と比べてシリアライゼーション負荷を約 33% 削減。帯域幅も削減される。

2.3 関心管理(Interest Management)

すべてのエンティティを全クライアントに送信するのではなく、各プレイヤーのビューポート内のエンティティのみをレプリケーションする。
// ビューポート半径 = カメラズームに合わせて質量で動的計算
pub fn viewport_radius_for_mass(total_mass: f32) -> f32 {
    let base_viewport = 640.0;
    let scale = (1.0 + (total_mass / 500.0).sqrt()).clamp(1.0, 5.0);
    base_viewport * scale * 1.5  // 1.5倍マージンでエッジのチラつき防止
}
最適化1: スロットリング(6ティックごと = 約5Hz)
fn rebuild_interest_grid(mut grid: ResMut<InterestGrid>, ..., mut tick: Local<u32>) {
    *tick += 1;
    if *tick % 6 != 0 { return; }  // 5Hzに間引き
    // グリッド再構築...
}
関心管理は高頻度で更新する必要がない。プレイヤーが画面端から離れるまで数十ms はかかるため、5Hz で十分。
最適化2: セット差分(Set-Diff)
可視エンティティの変更を、全エンティティのスキャンではなく前回との差分で検出:
struct InterestState {
    prev: HashMap<u64, HashSet<Entity>>,  // 前回の可視エンティティ
    curr: HashMap<u64, HashSet<Entity>>,  // 今回の可視エンティティ
}

// Phase 2: 差分検出
for &entity in visible_set {
    let was_visible = prev_set.map_or(false, |prev| prev.contains(&entity));
    if !was_visible {
        rs.gain_visibility(sender_entity);  // 新しく見えた
    }
}
for &entity in prev {
    if !visible_set.contains(&entity) {
        rs.lose_visibility(sender_entity);  // 見えなくなった
    }
}
最適化3: アロケーション再利用(prev/curr スワップ)
// HashSet のメモリを解放せず、prev ↔ curr をスワップして再利用
std::mem::swap(&mut s.prev, &mut s.curr);
効果: gain_visibility / lose_visibility の呼び出し回数が、変化したエンティティ数に比例。全エンティティ数には比例しない。メモリアロケーションもゼロ。

3. サーバー側の計算最適化

3.1 空間ハッシュグリッド + ダーティバケット追跡

衝突検出と関心管理に空間ハッシュグリッドを使用。さらに「ダーティバケット」パターンで clear() を高速化:
pub struct SpatialGrid {
    buckets: Vec<Vec<Entity>>,
    dirty: Vec<usize>,  // 書き込まれたバケットのインデックスのみ記録
}

impl SpatialGrid {
    // 全バケットではなく、使用されたバケットのみクリア
    pub fn clear(&mut self) {
        for &idx in &self.dirty {
            self.buckets[idx].clear();
        }
        self.dirty.clear();
    }
}
6000×6000 のマップを 200px グリッドで分割すると 30×30 = 900 バケットだが、エンティティが集中する領域は限られる。ダーティ追跡により、実際に使用された数十バケットのみをクリアすれば良い。

3.2 コールバックベースのグリッドクエリ(ゼロアロケーション)

// Vec を返すのではなく、コールバックで呼び出し元に直接渡す
pub fn query_aabb_cb(&self, center: Vec2, radius: f32, mut f: impl FnMut(Entity)) {
    // ...
    for &entity in &self.buckets[idx] {
        f(entity);  // アロケーションなし
    }
}
呼び出し側では再利用可能なバッファを使う:
let mut nearby = Vec::new();  // ループ外で宣言(再利用)
for ... {
    nearby.clear();
    grid.0.query_aabb_cb(pos, radius, |e| nearby.push(e));
}

3.3 OwnerIndex — O(C×K) の入力処理

問題: 入力処理で「このクライアントが所有するセルは?」を毎ティック検索する必要がある。ナイーブ実装は O(C×P)(C=クライアント数, P=全プレイヤーセル数)。
解決策: ティック開始時に owner_id → セル一覧のインデックスを一度だけ構築:
#[derive(Resource, Default)]
pub struct OwnerIndex {
    pub map: HashMap<u64, Vec<(Entity, Vec2, f32)>>,
}

fn rebuild_owner_index(mut index: ResMut<OwnerIndex>, cells: Query<...>) {
    index.map.clear();
    for (entity, transform, cell, pc) in cells.iter() {
        index.map.entry(pc.owner_id).or_default().push((
            entity, transform.translation.truncate(), cell.mass,
        ));
    }
}
入力処理、分裂、射出、関心管理の4つのシステムがこのインデックスを共有。各システムが独自にフルスキャンする必要がなくなる。
計算量: O(C×K)(K=クライアントあたりの平均セル数、通常1〜16)

3.4 永続バッファ(Local<T>)によるヒープアロケーション排除

Bevy の Local<T> を使い、システムのローカル状態をティック間で保持:
#[derive(Default)]
pub(crate) struct EatCellsState {
    cells: Vec<(Entity, Vec2, f32, f32, Option<u64>, bool)>,
    entity_map: HashMap<Entity, usize>,
    eaten: HashSet<Entity>,
    mass_gains: Vec<(Entity, f32)>,
    nearby: Vec<Entity>,
}

pub fn eat_cells(
    ...,
    mut state: Local<EatCellsState>,  // ティック間で再利用
) {
    let s = &mut *state;
    s.cells.clear();       // Vec の容量は保持(再アロケーションなし)
    s.entity_map.clear();
    s.eaten.clear();
    s.mass_gains.clear();
    // ...
}
効果: 毎ティック Vec/HashMap/HashSet を new するコストを排除。.clear() は内部バッファを解放しないため、数ティック後にはアロケーションが完全に安定する。

3.5 distance_squared によるナロウフェーズ最適化

// sqrt を避けて閾値も二乗で比較
let dist_sq = cell_pos.distance_squared(food_pos);
let threshold = cell_r + FOOD_RADIUS;
if dist_sq < threshold * threshold {
    // 衝突
}
distance() は内部で sqrt() を呼ぶが、大小比較だけなら distance_squared() で十分。衝突判定は毎ティック数百〜数千回呼ばれるため、この最適化の累積効果は大きい。

3.6 スロットリングされたリスポーンシステム

// 食べ物: 15ティックごと(約0.5秒)
pub fn respawn_food(..., mut tick: Local<u32>) {
    *tick += 1;
    if *tick % 15 != 0 { return; }
    // カウント → 不足分をスポーン
}

// ボット: 30ティックごと(約1秒)
pub fn respawn_bots(..., mut tick: Local<u32>) {
    *tick += 1;
    if *tick % 30 != 0 { return; }
    // ...
}
毎ティックすべての食べ物/ボットをカウントする必要はない。間引くことでクエリのイテレーション負荷を削減。

3.7 enforce_bounds の条件付き書き込み

pub fn enforce_bounds(mut cell_q: Query<&mut Transform, ...>) {
    for mut transform in cell_q.iter_mut() {
        let t = &transform.translation;
        // 範囲外の場合のみ Transform に書き込み
        if t.x < -hw || t.x > hw || t.y < -hh || t.y > hh {
            clamp_to_map(&mut transform.translation);
        }
    }
}
Bevy の Changed<Transform> 検知は、DerefMut が呼ばれた時点でトリガーされる。範囲内のエンティティの Transform に触れないことで、下流の sync_server_positionChanged<Transform> でフィルタ)のスキップ対象を増やす。

4. クライアント側のレンダリング最適化

4.1 共有メッシュによる GPU バッチング

問題: 食べ物 300 個に各自のメッシュ + マテリアルを持たせると、800+ のドローコールが発生。GPU バッチングが効かない。
解決策: 共有メッシュハンドルを使い、全エンティティで同一の GPU メッシュを参照:
#[derive(Resource)]
pub struct SharedMeshes {
    pub unit_circle: Handle<Mesh>,   // 半径1の円(セル・射出質量が共有)
    pub food_circle: Handle<Mesh>,   // 食べ物専用の小さい円
}
セルのサイズ変更は GPU メッシュの再アップロードではなく、Transform::scale で行う:
pub fn update_cell_sizes(
    mut query: Query<(&Cell, &mut Transform), Changed<Cell>>,
) {
    for (cell, mut transform) in query.iter_mut() {
        let radius = mass_to_radius(cell.mass);
        transform.scale = Vec3::splat(radius);  // メッシュは unit circle のまま
    }
}

4.2 Changed フィルタによる更新スキップ

// Cell コンポーネントが変更された場合のみ、名前テキストの位置を再計算
pub fn sync_cell_visuals(
    query: Query<(&Cell, &Children), (Changed<Cell>, ...)>,
    ...
) { ... }

// Cell コンポーネントが変更された場合のみ、Transform::scale を更新
pub fn update_cell_sizes(
    mut query: Query<(&Cell, &mut Transform), (Changed<Cell>, ...)>,
) { ... }
効果: 質量が変化していないエンティティはイテレーションから完全にスキップされる。

5. クライアント側の補間・予測

5.1 スナップショット補間(リモートセル)

リモートプレイヤーのセルは、サーバーから受信した2つの位置を線形補間:
// capture_server_position: 新しい ServerPosition を受信したら prev/curr を更新
target.prev_pos = target.curr_pos;
target.curr_pos = new_pos;

// apply_interpolation: 経過時間に基づいて prev → curr を補間
let elapsed = now - target.update_time;
let t = (elapsed / target.update_interval).clamp(0.0, 1.0);
let interpolated = target.prev_pos.lerp(target.curr_pos, t);
さらに、指数スムージングでマイクロジッターを抑制:
let smooth_t = 1.0 - (-15.0 * dt).exp();
transform.translation = current.lerp(interpolated, smooth_t);

5.2 クライアント側予測 + 穏やかなサーバー補正(自セル)

自分のセルは即座にマウス入力に反応させるため、サーバーと同じ物理演算をクライアント側でも実行:
// ステップ1: ローカル予測(サーバーと同じ物理)
let speed = max_speed(cell.mass);
let factor = (dist / CURSOR_SLOW_DISTANCE).min(1.0);
let delta = dir * speed * factor * dt;
transform.translation.x += delta.x;
transform.translation.y += delta.y;

// ステップ2: 穏やかなサーバー補正(rate=2)
let correction_rate = if drift > 100.0 { 10.0 } else { 2.0 };
let correction_t = 1.0 - (-correction_rate * dt).exp();
transform.translation = transform.translation.lerp(server_interpolated, correction_t);
なぜ rate=2 なのか:
方向転換時、予測とサーバー位置は最大 ~40px 乖離する。
  • 補正量: 40px × 3.3%/フレーム = 1.32px/フレーム
  • 予測量: ~3.33px/フレーム
  • 正味: 2.01px/フレーム(予測の 60% 速度)
セルは減速するが決して停止しない — 予測が常に補正を上回る。以前の rate=8 では補正が予測を圧倒し、セルが停止する問題があった。

5.3 フレームレート非依存の指数スムージング

すべてのスムージングに 1.0 - (-rate * dt).exp() 公式を使用:
// カメラ追従: 0.65^(dt*60) ベース
let lerp = 1.0 - 0.65_f32.powf(dt * 60.0);

// カメラズーム: 0.9^(dt*60) ベース
let lerp = 1.0 - 0.9_f32.powf(dt * 60.0);
効果: 30fps でも 144fps でも同じスムージング結果。固定 lerp 値(例: lerp(0.1))はフレームレート依存で、fps が変動するとスムージングの「硬さ」が変わる。

5.4 サーバー更新間隔の適応推定

let interval = now - target.update_time;
if interval > 0.01 && interval < 0.5 {
    // EMA(指数移動平均)で間隔を推定、0.5/0.5 の重みで ~5更新で収束
    target.update_interval = target.update_interval * 0.5 + interval * 0.5;
}
ネットワーク遅延のジッターで更新間隔が揺れるため、EMA でスムーズに推定。重み 0.5/0.5 は 0.7/0.3 より早く収束する(~5 更新 vs ~15 更新)。

6. フレームレート安定化

6.1 FixedUpdate デススパイラル防止

// サーバー・クライアント両方で設定
.insert_resource(Time::<Virtual>::from_max_delta(Duration::from_millis(66)))
問題: 1フレームに時間がかかると、FixedUpdate が遅れを取り戻すために大量のティックを実行 → さらに時間がかかる → さらにティックが増える(デススパイラル)。
解決策: 仮想時間のデルタを最大 66ms に制限。30 TPS の場合、1フレームあたり最大2ティックに抑制。デフォルトの 250ms だと 7-8 ティック/フレームが溜まり、カスケードフリーズを引き起こす。

6.2 サーバーの CPU 占有防止

// 1ms スリープで CPU を他プロセスに譲る
.add_plugins(MinimalPlugins.set(
    ScheduleRunnerPlugin::run_loop(Duration::from_millis(1))
))
デフォルトの MinimalPlugins はスリープなしで CPU を 100% 使用する。同一マシンでクライアントとサーバーを実行する場合、サーバーが CPU を独占してクライアントがフリーズする。Windows の実際のスリープ解像度は ~15ms だが、30 TPS には十分。

6.3 システム実行順序の明示的定義

// サーバー: Input → AI → Movement → Collision → PostCollision
.configure_sets(FixedUpdate, (
    GamePhase::Input,
    GamePhase::AI.after(GamePhase::Input),
    GamePhase::Movement.after(GamePhase::AI),
    GamePhase::Collision.after(GamePhase::Movement),
    GamePhase::PostCollision.after(GamePhase::Collision),
))

// クライアント: input → interpolation → rendering → camera
.add_systems(Update, (
    input::capture_input,
    interpolation::apply_interpolation,
).chain())
明示的な順序定義により、フレーム内の不確定な実行順序によるバグ(1フレーム遅延、ジッター)を排除。

7. 教訓: InputPlugin の落とし穴と直接メッセージ送信

開発中に発生した最大のバグは、プレイヤーの入力がサイレントにドロップされる問題でした。

問題

Lightyear の InputPlugin はエンティティマッピングに依存しています。接続エンティティ(InputMarker を持つ)はレプリケーション対象のゲームエンティティではないため、サーバー側のエンティティマップに存在しません。その結果、InputTarget::EntityEntity::PLACEHOLDER に解決され、入力メッセージがサイレントに破棄されていました。

症状

  • セルが時々マウス方向に動かなくなる
  • 急な方向転換時に特に発生しやすい
  • 一見、補間/予測のバグに見える(実際は入力のドロップが原因)

解決策

InputPlugin を完全にバイパスし、PlayerInput を直接メッセージとして送信:
// protocol.rs — InputPlugin の代わりに直接メッセージ登録
app.register_message::<PlayerInput>()
    .add_direction(NetworkDirection::ClientToServer);

// client/input.rs — MessageSender で直接送信
for mut sender in senders.iter_mut() {
    let _ = sender.send::<UnreliableChannel>(PlayerInput {
        mouse_world_pos: world_pos,
        actions: net_input.actions.clone(),
    });
}

// server/network.rs — LatestInputs リソースで受信・保持
#[derive(Resource, Default)]
pub struct LatestInputs(pub HashMap<u64, PlayerInput>);

fn receive_player_inputs(
    mut receivers: Query<(Entity, &mut MessageReceiver<PlayerInput>), With<LinkOf>>,
    mut latest: ResMut<LatestInputs>,
) {
    for (client_entity, mut receiver) in receivers.iter_mut() {
        let client_id = client_entity.to_bits();
        for msg in receiver.receive() {
            latest.0.insert(client_id, msg);
        }
    }
}

Update vs FixedUpdate の分離

メッセージ受信は Update で、ゲームロジックへの適用は FixedUpdate で行う:
// Update: メッセージを受信して LatestInputs に格納
app.add_systems(Update, receive_player_inputs);

// FixedUpdate: LatestInputs を読み取ってゲームに適用
app.add_systems(FixedUpdate, (
    process_player_inputs,
    handle_split_action,
    handle_eject_action,
).chain().in_set(GamePhase::Input));
理由: Lightyear の MessageReceiver バッファは Last スケジュールでクリアされる。メッセージが到着したフレームで FixedUpdate がティックしない場合、次の FixedUpdate までにメッセージが失われる。Update で受信して Resource に保存することで、FixedUpdate のタイミングに関係なくメッセージを保持できる。

まとめ

カテゴリ最適化効果
ネットワークServerPosition 分離クライアント補間の自由度確保
50ms バッチングシリアライゼーション負荷 33% 削減
関心管理 + セット差分帯域幅を可視エンティティ数に比例に削減
関心管理スロットリング(5Hz)グリッド再構築頻度 83% 削減
サーバー計算空間ハッシュグリッド + ダーティバケットO(n) 衝突検出 + 高速クリア
OwnerIndex入力処理 O(C×P) → O(C×K)
Local<T> 永続バッファ毎ティックのヒープアロケーション排除
distance_squaredsqrt 回避(数千回/ティック)
スロットリング(食べ物/ボットリスポーン)不要なクエリ反復削減
レンダリング共有メッシュ + パレットマテリアル800+ → ~16 ドローコール
unit circle + Transform::scaleGPU メッシュ再アップロード不要
Changed フィルタ変更なしエンティティのスキップ
クライアント補間スナップショット補間サーバー間隔1回分の遅延でスムーズな動き
予測 + 穏やかな補正(rate=2)自セルの入力遅延 0ms
フレームレート非依存スムージングfps 変動に対する一貫性
安定性max_delta 制限FixedUpdate デススパイラル防止
1ms スリープサーバーの CPU 占有防止
入力InputPlugin バイパス入力のサイレントドロップ防止
Update/FixedUpdate 分離メッセージ消失防止

Discussion

コメントにはログインが必要です。