【備忘録】Bevy + Lightyear におけるパフォーマンス最適化
【この記事の内容】
Bevy 0.18 と Lightyear 0.26 を使用した一般的なリアルタイムマルチプレイヤーゲームにおける、パフォーマンス最適化のアプローチをまとめ
TL;DR
- アーキテクチャ概要
- ネットワーク最適化
- サーバー側の計算最適化
- クライアント側のレンダリング最適化
- クライアント側の補間・予測
- フレームレート安定化
- 教訓: InputPlugin の落とし穴と直接メッセージ送信
1. アーキテクチャ概要
┌─────────────┐ WebSocket ┌─────────────┐
│ Client │ ◄──────────────────────► │ Server │
│ (Bevy + │ PlayerInput (30Hz) │ (Bevy + │
│ Rendering) │ ServerPosition (20Hz) │ Lightyear) │
└─────────────┘ └─────────────┘
前提条件
- サーバー権威型: ゲームロジックはすべてサーバーで実行
- サーバー: 30 TPS(FixedUpdate)でシミュレーション、20Hz でクライアントにレプリケーション
- クライアント: 60fps でレンダリング、スナップショット補間 + 自セル予測で滑らかな表示
2. ネットワーク最適化
問題: 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_position(Changed<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フレーム遅延、ジッター)を排除。
開発中に発生した最大のバグは、プレイヤーの入力がサイレントにドロップされる問題でした。
問題
Lightyear の InputPlugin はエンティティマッピングに依存しています。接続エンティティ(InputMarker を持つ)はレプリケーション対象のゲームエンティティではないため、サーバー側のエンティティマップに存在しません。その結果、InputTarget::Entity が Entity::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_squared | sqrt 回避(数千回/ティック) |
| スロットリング(食べ物/ボットリスポーン) | 不要なクエリ反復削減 |
| レンダリング | 共有メッシュ + パレットマテリアル | 800+ → ~16 ドローコール |
| unit circle + Transform::scale | GPU メッシュ再アップロード不要 |
| Changed フィルタ | 変更なしエンティティのスキップ |
| クライアント補間 | スナップショット補間 | サーバー間隔1回分の遅延でスムーズな動き |
| 予測 + 穏やかな補正(rate=2) | 自セルの入力遅延 0ms |
| フレームレート非依存スムージング | fps 変動に対する一貫性 |
| 安定性 | max_delta 制限 | FixedUpdate デススパイラル防止 |
| 1ms スリープ | サーバーの CPU 占有防止 |
| 入力 | InputPlugin バイパス | 入力のサイレントドロップ防止 |
| Update/FixedUpdate 分離 | メッセージ消失防止 |