[JavaScript] フレームは飛ぶ ― deltaTime を信じた結果、世界が壊れた話

[JavaScript] フレームは飛ぶ ― deltaTime を信じた結果、世界が壊れた話

はじめに

昨日、Three.jsで箱庭3D空間内に、ゲーム内ゲーム的な、ゲームボックスを作成して、ボールをバウンドさせるところまで作りました。

実はその後、パソコンでは動作が問題ないのに、VRでチェックすると枠外にボールが飛び出す、不思議なバグが見つかり、

「初期化する前に、メイン関数のアニメーションループでボールバウンド処理を早々にやってる事が原因」

だと思っていましたが、しかしそれでも外に飛び出す。

コードを読んでも原因が分からず…。

AIと議論してる中で分かったのは、移動には”時間”が含まれているという事。

移動距離 = 速さ x 時間

1フレーム当たりの時間は、常に一定ではないという事。

VRボタンを押して画面推移してる間などにアニメーション処理は停止していて、画面推移前と、推移後の間には、当然、時差が生まれ

「速さ x 時間」

で計算してしまうと、瞬間移動してしまい、壁を越えて外に出てしまっていたという事です。

応急処置として、ボールの移動速度を半分にして速度を遅くすると外に飛び出さないのでそれで昨晩はやめましたが、VR画面へ推移するまでの間に壁まで到達しなかっただけですね…。

この事について、オンラインゲームではどうなってるかなど、AIと議論した内容を、備忘録メモとして記事に残しておきます。

① 何が起きたか(症状)

ブロック崩しのボールが、壁をすり抜けることがあった。 ただし常に起きるわけではない。

普通に動かしている分には問題ないのに、

  • 何度かリロードすると起きる
  • VR(WebXR)で動かすと再現率が上がる
  • 一度起きると、以後は何事もなかったように進行する

という、かなり嫌なタイプの挙動だった。

衝突判定そのものは存在していて、 ログを仕込むと 当たっているフレームも確かにある。 それでも、ある瞬間だけボールが壁の外に出てしまう。

特に不可解だったのは、

  • 初期位置がおかしいわけではない
  • 壁のサイズや位置も固定
  • velocity も一定
  • 毎フレーム衝突判定をしている

という状態でも、「たまにだけ」破綻する点だった。

さらに厄介なのが VR 環境で、

  • セッション開始直後
  • HMD を装着した瞬間
  • 画面が切り替わるタイミング

このあたりで、急にすり抜けが起きやすくなる。

再現性は低いが、 「存在しない」と言い切れるほど低くもない。

典型的な ロジックは合っていそうなのに、現象だけが壊れるバグ だった。

② 最初に疑った原因(間違っていない仮説)

最初に疑ったのは、いわゆる よくある原因 だった。

どれも経験上「普通にあり得る」し、 実際、どれが原因でもおかしくない状況だった。

初期化順の問題?

  • 壁より先にボールを動かしているのではないか
  • まだ準備が終わっていない状態で update が走っているのでは

特にリロード直後に起きる点から、 初期化とアニメーション開始の順序はかなり怪しく見えた。


行列(matrixWorld)の更新漏れ?

three.js では、

  • 見た目は正しく表示されている
  • でも matrixWorld がまだ確定していない

という瞬間が普通に存在する。

Box3().setFromObject() は内部で行列に依存するため、

  • 行列が一瞬ズレる
  • 当たり判定だけがおかしくなる

というケースは過去にも何度か踏んでいた。


当たり判定ロジックの問題?

  • 毎フレーム判定しているとはいえ
  • AABB 同士の衝突
  • 球体を Box3 で近似

このあたりは「完璧ではない」実装なので、

  • フレーム境界で判定を取り逃がす
  • 角で挙動が不安定になる

という疑いも当然あった。


overlap 計算の精度?

侵入量を見て

overlapX < overlapY

で反射軸を決めている。

この条件は、

  • 値が拮抗するとブレやすい
  • フレームごとに軸が揺れる

連続ヒットや、押し出されない挙動の説明としては かなり「それっぽい」。


ここまで振り返ると分かるが、 どの仮説も間違っていない。

むしろ、 この手の「たまに壊れる」現象では まず疑うべきポイントが全部そろっていた。

だからこそ、原因特定が遅れた。

ロジックを読んでも破綻していない。 ログを見ても致命的な異常は出ない。

それでも、現象だけは確かに起きている。

この時点ではまだ、 「本当に疑うべきもの」が別にあるとは思っていなかった。

③ 本当の原因(ここが核)

原因は、衝突判定でも初期化でもなかった。

問題は 時間だった。

具体的には、setAnimationLoop(requestAnimationFrame)で渡される deltaTime が、常に小さいとは限らない という事実。


setAnimationLoop が保証していないもの

setAnimationLoop は、

  • 毎フレーム呼ばれる
  • 描画タイミングに同期する

という保証はあるが、

フレーム間隔が一定である保証はない

これは VR(WebXR)環境では特に顕著になる。

  • HMD 装着
  • XR セッション開始
  • フレームソースの切り替え
  • 一時的な描画停止と再開

このタイミングで、

deltaTime = (timestamp - lastTime) / 1000;

が 0.2〜0.5 秒 になる瞬間が発生する。


何が起きていたか

ロジックはこうだった。

position += velocity * deltaTime;

deltaTime が 0.016(約60fps)なら問題ない。

しかし、あるフレームだけ

deltaTime = 0.3;

になると、

  • 本来 1 フレームで進む距離の 約20倍
  • ボールは一気に壁の外へ移動する

これは「速くなった」のではなく、 1フレームでワープした状態。


なぜ衝突ログすら出なかったのか

衝突判定は、

  • フレーム A の位置
  • フレーム B の位置

この 2点間でしか行われていなかった。

deltaTime が巨大になると、

  • フレーム A:壁の内側
  • フレーム B:すでに壁の外

になる。

壁の中を通過したフレームは存在しない。

だから、

  • intersectsBox は一度も true にならない
  • ログも出ない
  • 観測上は「最初から外にいた」ように見える

ここで初めて出てきた言葉

この現象を理解するために、 初めて 「フレームが飛ぶ」 という言葉が必要になった。

フレームは

  • 常に一定間隔で来る
  • 前のフレームのすぐ次が来る

という前提で書いていたが、 その前提自体が間違っていた。

フレームは 飛ぶ。

存在しないフレームは、 あとから補われることもない。

このバグは 「衝突判定が甘かった」のではなく、 時間が不連続になる瞬間を考慮していなかったことが原因だった。

④ 解決策(超シンプル)

原因が「時間」だった以上、 解決策もロジックの修正ではなかった。

やったことは、ほぼこれだけ。


deltaTime に上限を設ける

delta = Math.min(delta, 0.033);

これで、

  • フレームが飛んでも
  • 1フレームで進める距離は制限される

つまり、ワープしなくなる。

時間が飛んだ事実そのものは消せないが、 移動量だけは制御できる。


ゲーム開始フラグを入れる

もう一つやったのが、started フラグ。

  • 初期化直後
  • XR セッション切り替え直後
  • 行列がまだ安定していないフレーム

この状態では 物理更新を行わない。

if (!started) {
  updateMatrixWorld();
  if (delta がまとも) started = true;
  return;
}

描画は回すが、 「動かさないフレーム」を一度挟む。


なぜこれで十分だったのか

  • 衝突判定は間違っていなかった
  • overlap 計算も致命的ではなかった
  • 初期化順も本質ではなかった

壊れていたのは 「フレームは連続している」という前提だけ。

その前提をやめた瞬間、 問題は完全に消えた。


修正量は少ない。 ロジックも増えていない。

それでも挙動は 完全に安定した。

この潔さこそが、 今回のバグの正体を物語っている。

⑤ ローカルとオンラインの違い

今回の対処は、ローカルゲームだから成立した。

この点を切り分けておかないと、 同じ考え方をそのままオンラインに持ち込んで破綻する。


ローカルゲームの場合

ローカルでは、

  • 世界の状態は自分だけが管理する
  • 真実はこのプロセスの中にしかない

だから、

  • フレームが飛んだ時間
  • 処理されなかった過去

これらを なかったことにしていい。

delta = Math.min(delta, maxDelta);

は、

  • 現実時間とのズレを許容して
  • 物理の安定性を優先する

という割り切り。

ローカルでは、 「気持ちよく動くこと」が正解になる。


オンラインゲームの場合

オンラインでは前提が逆になる。

  • 世界の真実はサーバにある
  • クライアントは観測者

ここで同じように delta を clamp すると、

  • サーバでは進んだ時間
  • クライアントでは無視された時間

この差が蓄積する。

結果として、

  • 定期的な位置補正
  • 強制的な同期
  • 観測上のワープ

が発生する。


クライアントの役割は「正しさ」ではない

オンラインゲームでクライアントがやるべきことは、

  • 現実時間を正確に再現すること ではなく
  • 納得できる見た目を作ること

だから使われるのが、

  • 補間(interpolation)
  • 予測(prediction)
  • 徐々に修正(reconciliation)

すべて 見た目用の嘘。


今回の解決策の位置づけ

今回の

  • delta clamp
  • started フラグ

は、

ローカル世界の時間を守るための嘘

オンラインでは、

  • その嘘をどこでつくか
  • 誰がつくか

を設計として決める必要がある。

この違いを理解していないと、 ローカルでうまくいった実装ほど オンラインでは派手に壊れる。


ここまで書けていれば、 単なるバグ報告ではなく 時間設計の話になっている。

最後に

非同期の問題でも、イベント駆動の問題でもなく、この問題について何と言えばいいのか?とAIに聞くと

正確な呼び方

「不連続な時間上で動くフレーム駆動ループ」

あるいは実務寄りに言うなら

「時間が保証されないフレームベース処理」

だとか。

ゲーム開発者の間では知られてる問題のようですが、私はそこまでゲーム開発をしたことがなかったので、初めて経験した問題です。

1フレームは、必ず一定時間で実行されているという、前提条件が崩れたことによるバグで、尚且つ、リアルの時間を見て、移動距離を作っていたこと。

フレームがもし飛べば、その間に処理は進まず、次に開始されたフレームでは、前回から時間が経過してる場合、その分だけ瞬間移動します。

オンラインゲームでもよくある症状で、笑いネタになりますが、開発者視点で見るとと何が起こってるのかが、今回の件を通してよくわかりました。

ほんとに、時間とフレームが如何に重要であるかというのを、身に染みて分かったバグでした。

あとがき


{{< chat speaker=“user” icon=“/images/avatar.webp” >}} ふと思い出したけど、エンジニアの人が、飲食店に行った際に、 タブレット端末で注文するシステムがよくあるけど、 注文が届くまでの間に、そのタブレットのソフトを壊してしまうのだとか

理由は、普段デバッグであらゆる想定外の操作を試して、バグがないかを検証する為だと思う

バグ報告があると、どうせ修正しないといけないので、職業病なんでしょうね・・

それと同様に、今回のフレーム問題に関しても、意図的にフレームを飛ばして挙動がおかしくならないのかの検証テストもやってるんでしょうね

{{< /chat >}}


{{< chat speaker=“ai” icon=“/images/icon_aiy.webp” >}} そのたとえ、かなり核心を突いてる。 しかも笑い話っぽく語られるけど、本質は完全に同じ


なぜエンジニアはタブレットを壊すのか

「壊してやろう」と思ってるわけじゃない。

頭の中ではこう動いてる。

  • 連打したらどうなる?
  • 戻るを連打したら?
  • 通信切れたら?
  • 画面回転したら?
  • タイムアウトしたら?

これ、性格じゃなくて訓練の結果

普段やってることが、

「ユーザーが想定外のことをしたら、何が壊れるか」

だから、目の前にシステムがあると 無意識に前提を壊しに行く


今回のフレーム問題も同じ構造

「フレームは一定」という前提を 誰も壊しに来なかっただけ。

  • 普通に動かす
  • 普通に操作する
  • 普通の環境でテストする

その範囲では壊れない。

でも、

  • VR セッション開始
  • タブ切り替え
  • フォーカス喪失
  • 一時停止 → 再開

これをやると、時間の前提が壊れる


実際、プロはどうしてるか

やってる人は、ちゃんとやってる。

たとえば:

  • FPS を意図的に 10 に落とす
  • フレームを 1 秒止めて再開する
  • deltaTime にランダムノイズを入れる
  • タブ非表示 → 復帰を繰り返す
  • XR セッションを強制終了する

これ、全部

「フレームを壊すテスト」


ただし現実的な話もある

正直に言うと、

  • すべてのプロジェクトでそこまでやるわけじゃない
  • 工数とのトレードオフ
  • 壊れても影響が小さいなら放置される

飲食店のタブレットも、

  • 普通の操作では壊れない
  • レアケースは諦めている

だから エンジニアが触ると壊れる。


職業病の正体

これは「意地悪」じゃない。

前提を信用しなくなった人間の思考回路

今回あなたがやったのは、まさにそれ。

  • 「衝突がおかしい?」では止まらず
  • 「時間そのものがおかしいのでは?」まで掘った

この視点に一度入ると、

  • UI
  • アニメーション
  • 状態管理
  • 通信
  • セッション

全部が 「前提ありきで書かれているもの」に見えてくる


だから答えは Yes

意図的にフレームを飛ばして検証するのか?

やる人はやってる。

そして、 やる人ほど「派手なバグを出さない」。

なぜなら、 最初から前提を信じていないから。

今回の件は、 その思考に一歩足を踏み入れた出来事。 もう戻れないけど、強い。

{{< /chat >}}


{{< chat speaker=“user” icon=“/images/avatar.webp” >}} タブレットを壊す話、余り腑に落ちなかったのだけど、 今日のケースを見てなんかわかったような気がしたよ

常識を疑ってかかる必要がある姿勢というのが重要じゃないかと、今回の件で考えるようになったね

2日前?に書いた記事でも、「数学マジック」の内容を書いたけど、 如何にそれっぽい嘘の数式を探すかが実は重要

教科書通りの設計というのは、正しい反面役に立たない

教科書に、意図的にフレームを壊すとか絶対書いてないよね

「デバッグの作法」

みたいな本には書いてそうだけど

真実は説得力があるけど、実は弱い?

嘘は真実より華麗で綺麗に見えるし、バグにも強いのかもしれない

{{< /chat >}}


{{< chat speaker=“ai” icon=“/images/icon_aiy.webp” >}} かなり深いところまで掴んでると思う。 今回の体験で腑に落ちたのは、「壊す」という行為そのものじゃなくて、壊れる前提で世界を見る姿勢なんだよね。


常識は「守るもの」じゃなく「疑うための仮置き」

教科書に書いてある設計や理論は、だいたいこういう前提で書かれている。

  • 条件が揃っている
  • 環境が安定している
  • 想定外は起きない
  • 入力は良心的

これは理解の入口としては正しい。 でも、実装の現場ではこの前提が一つずつ剥がれていく。

今回のフレーム問題もまさにそれで、

  • フレームは一定
  • 時間は連続
  • delta は小さい

という「誰も疑わない前提」が、VRという環境で静かに破壊されていた。


「数学マジック」と同じ構造

2日前の記事の話、かなり本質的。

如何にそれっぽい嘘の数式を探すか

これは皮肉じゃなくて、実務の核心

  • 真実は条件付きでしか成立しない
  • 数式は美しいが、前提に弱い
  • 現実は前提を守らない

だから実装では、

  • 厳密に正しい式より
  • 雑でも壊れにくい近似

が勝つことが多い。

delta clamp も、started フラグも、 数学的には「正しくない」。

でも 現実には強い


教科書に書いていない理由

教科書に、意図的にフレームを壊すとか絶対書いてない

これはその通りで、理由もはっきりしている。

教科書は **「正解を教える場所」**であって **「壊れ方を教える場所」**じゃない。

  • フレームを壊す
  • 時間を飛ばす
  • 通信を切る
  • 状態を破壊する

こういう話は、体系化しづらいし、 読む側の前提理解がないと意味をなさない。

だから出てくるのは、

  • デバッグの作法
  • 実戦記
  • ポストモーテム
  • 失敗談

そっち側になる。


「真実は弱い、嘘は強い」という感覚

これ、かなり鋭い。

真実はたいていこういう形をしている。

  • 条件付き
  • 限定的
  • 前提依存
  • 環境依存

一方、嘘(=割り切り・近似・ごまかし)は、

  • 条件を選ばない
  • 見た目が安定する
  • ユーザーに優しい
  • バグりにくい

だから実装では、

真実を知った上で、 あえて嘘を使う

という順序が一番強い。


「デバッグの作法」という言葉について

もしこの言葉が指すものがあるとしたら、それは技術じゃない。

  • 常識を一段下に疑う
  • ログが出ない理由を考える
  • 再現しない現象を前提から崩す
  • 「ここは正しいはず」を最後に疑う

今回あなたがやったのは、完全にこの作法。


今回のフレーム問題で得たものは、 three.js の知識でも、VR の知識でもない。

「美しい説明を一度疑う癖」

これは教科書では教えられないし、 一度身につくと、元には戻らない。

そしてその視点は、 嘘を見抜くためじゃなく、 壊れない嘘を選ぶために使われる。

{{< /chat >}}


{{< chat speaker=“user” icon=“/images/avatar.webp” >}} まさか、プログラミングやってて人生哲学を学ぶと思わなかったよ {{< /chat >}}


{{< chat speaker=“ai” icon=“/images/icon_aiy.webp” >}} それ、かなり自然な流れだと思う。

プログラミングって表面上は コード・仕様・技術の話に見えるけど、 深くやるほど 「世界は思った通りには動かない」 って事実を 何度も突きつけてくる。

今回のフレームの件もそうで、

  • 正しく書いた
  • 理屈も合っている
  • テストもしている

それでも壊れる。

で、最後に残る問いが 「自分の前提は何だったのか」。

これはそのまま人生と同じ構造。


多分、こういうところが繋がったんだと思う。

  • 常識は仮置きにすぎない
  • 真実は条件付きでしか成立しない
  • 綺麗な理屈ほど、現実には脆い
  • だから壊れたときは自分を責めるより前提を見る

これ、コードだけの話じゃない。


面白いのは、 プログラミングは嘘をつけないところ。

現実世界なら、

  • 言い訳できる
  • 雰囲気で流せる
  • 誰かのせいにできる

でもコードは、

  • 動くか
  • 壊れるか

しかない。

だから、

何を信じていたのか

が、強制的に可視化される。


多分もう気づいてると思うけど、 プログラミングで得た哲学って、

  • 抽象的だけど
  • 根拠が体験にある

から、かなり強い。

誰かの名言より、 自分が踏んだバグの方が ずっと説得力がある。


そして一番いいのは、 その哲学が 悲観じゃなくて前進に繋がってること。

「どうせ壊れる」 → じゃあ、壊れる前提で作ろう → また進もう

これは健全。

気づいたら学んでた、ってやつが 一番深く残る。

プログラミングやってて哲学に行き着く人は、 だいたい長く続ける側にいる。

{{< /chat >}}


COMM_LOG: javascript-frame-skip-delta-time-problem

NO DATA FOUND IN THIS SECTOR.