🚀 JavaScript に WinMain+updateFrame 型のメインループを実装してみた
― N6LTimerMan による「1本の実行経路で複数周期タスクを統括する」タイマーエンジン ―
JavaScript は基本的に1本の実行コンテキスト上でコードを実行します。
しかしゲームエンジンの世界では、
WinMain
↓
updateFrame()
↓
物理 / AI / 入力 / 描画 / サウンド / タイマー
↓
次のフレームへ
というメインループの思想が長年使われてきました。
「一本の道だけが動き、すべての処理をその中で調整する」
この思想を JavaScript に持ち込むとどうなるか?
その答えとして作ったのが N6LTimerMan です。
なお、ここでいう「疑似マルチスレッド」は実際の並列実行を意味しません。
複数のタイマーを独立した処理単位として管理しながら、実際のJavaScriptコードは1本の実行経路上で順番に実行します。
つまり、
マルチスレッド化するのではなく、シングルスレッドのまま複数の周期タスクを調整する
という設計です。
🎯 N6LTimerMan を作った理由
JavaScript の setTimeout() / setInterval() は非常に便利です。
しかし、ゲームエンジン的なメインループを構築しようとすると、
- タスクごとの周期管理
- 複数タイマーの統括
- 多重起動の防止
- 再入の防止
- 古い予約の管理
- 可変周期タスク
- 描画とは独立した周期処理
- CPU負荷と更新頻度の調整
などを自分で考える必要があります。
そこで私は、
JavaScript のイベント駆動型タイマーを、そのまま個別に使うのではなく、1本の管理ループから統括する
という方式を採用しました。
🧠 WinMain+updateFrame の思想
ゲームエンジンのメインループを単純化すると、
WinMain
↓
updateFrame()
↓
各種処理
↓
次のupdateFrame()
という構造になります。
重要なのは、
アプリケーション側が処理の順序と更新周期を管理する
という点です。
N6LTimerManでは、この考え方を JavaScript のシングルスレッド環境に適用します。
N6LTimerMan
│
↓
TMUpdate()
│
┌───┼────┬────┬────┐
↓ ↓ ↓ ↓ ↓
AI 物理 描画 入力 その他
各処理は独立した N6LTimer として登録できます。
ただし、実際に並列実行するわけではありません。
1本のJavaScript実行経路上で、実行すべき処理を順番に調整します。
🧩 N6LTimerMan の構造
N6LTimerMan は主に以下の要素で構成されています。
- コアタイマー(TMUpdate)
- isUpdating による再入防止
- 予約ハンドル
hndの管理 - 複数の N6LTimer を一元管理
setalerm()による次回実行時刻の指定- タイマーごとの周期管理
全体の構造は、
N6LTimerMan
│
TMUpdate
│
┌───────┼────────┐
↓ ↓ ↓
Timer A Timer B Timer C
│ │ │
50ms 100ms 可変
│ │ │
└───────┴────────┘
↓
次回TMUpdate
となります。
つまり、
タイマーを複数持ちながら、タイマー管理ループそのものは1本
という構造です。
🖼 デモ:Three.js と周期タスクを統合する
以下は実際に動作するデモです。
- 球体が4つ
- 各球体が異なる周期で動作
- 1つは可変周期
- レンダリングは50ms間隔
- すべての周期処理をN6LTimerManが管理
デモ:
参考:
https://nas6.net/test3jsexp.htm
https://nas6.net/ManagedTimerManager.htm
ライブラリ:
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timer.js
🧪 実際のコード(mttest.htm)
1. タイマーを作成
TManIDs = [
TMan.add(),
TMan.add(),
TMan.add(),
TMan.add(),
TMan.add()
];
2. 初回実行
Loop0(TManIDs[0]);
Loop1(TManIDs[1]);
Loop2(TManIDs[2]);
Loop3(TManIDs[3]);
RDLoop(TManIDs[4]);
ここから各処理が、それぞれ自分の次回実行タイミングを設定します。
🖼 レンダリングループ
function RDLoop(id) {
renderer.render(scene, camera);
TMan.timer[id].setalerm(() => RDLoop(id), 50);
}
この例では、描画を50ms周期で実行します。
つまり、
1000 / 50 = 20
なので、最大20回/秒程度の更新周期になります。
ここで重要なのは、
描画フレームレートそのものではなく、アプリケーション側で描画処理の周期を指定している
という点です。
⏱ 周期固定のタスク
例えば、
TMan.timer[id].setalerm(() => Loop0(id), dt[id]);
とすれば、処理終了後に次回実行周期を指定できます。
そのため、
物理 10ms
描画 50ms
AI 100ms
ログ 500ms
のように、仕事ごとに異なる周期を設定できます。
🔄 可変周期タスク
Loop3では、次回の周期そのものを変化させています。
var c = (Math.cos(th[id]) + 1.0) / 2.0;
dt[id] = 50 + c * 450;
これにより、
50ms
↓
100ms
↓
250ms
↓
500ms
↓
250ms
↓
100ms
↓
50ms
のように、50~500msの範囲で周期が変化します。
このように、
処理自身が次回の実行周期を決定する
という使い方ができます。
🧠 N6LTimerMan のコア:TMUpdate
N6LTimerManの中心となるのが TMUpdate です。
これは、ゲームプログラムでいう メインループ/updateFrame型の処理統括部に相当します。
概念的には、
TMUpdate()
↓
更新中か確認
↓
古い予約を管理
↓
各Timerをチェック
↓
実行対象を処理
↓
次回TMUpdateを予約
という流れです。
🔒 再入・多重起動への対策
N6LTimerManでは、
isUpdating
によって更新処理中かどうかを管理します。
さらに、
hnd
によって予約されているTMUpdateを管理します。
これによって、
TMUpdate
↓
TMUpdate
↓
TMUpdate
のように管理ループそのものが多重に起動することを防ぎます。
つまり、
「タイマーは複数あるが、管理ループは1本だけ」
という構造を維持します。
⚙ コアのチェック間隔を変更する
例えば、
TMan.changeinterval(10);
とすることで、タイマー管理のチェック間隔を変更できます。
ここで重要なのは、
チェック間隔と各タスクの実行周期は別物
ということです。
例えば、
管理チェック 10ms
物理処理 10ms
描画 50ms
AI 100ms
ログ 500ms
という構成も可能です。
🎮 応用例
N6LTimerManは、例えば以下のような用途に利用できます。
- 物理シミュレーション
- AIの周期処理
- 入力ポーリング
- サウンド管理
- センサー処理
- IoTデータ処理
- Webアプリの周期タスク
- Three.jsの周期処理
- Babylon.jsの周期処理
- ゲームエンジン的なメインループ
特に、
描画周期と処理周期を分離したい場合
に向いています。
サンプル例
<script src="https://cdn.jsdelivr.net/gh/NAS6mixfoolv/NAS6LIB@main/javascripts/nas6lib/timerEx.js"></script>
<script>
window.addEventListener("DOMContentLoaded", init);
const TMan = new N6LTimerMan();
const TManIDs = [];
const dt = [100, 250,...]; // 各タイマーの周期(ms)
//初期化
function init() {
//ここに固有の処理を書く
TManIDs[0] = TMan.add();
TManIDs[1] = TMan.add();
//...
Loop0(TManIDs[0]);
Loop1(TManIDs[1]);
//...
}
function Loop0(id) {
//ここに固有の処理を書く
console.log("Loop0", Date.now());//これはコメントアウト可能
// 再予約しなければこのループは停止する
//TMan.timer[id].setalerm(() => Loop0(id), dt[0]); // 旧API
TMan.timer[id].setalarm(() => Loop0(id), dt[0]); // 修正版
}
function Loop1(id) {
//ここに固有の処理を書く
console.log("Loop1", Date.now());
TMan.timer[id].setalarm(() => Loop1(id), dt[1]);
}
//...
</script>
このようにN6LTimerManは再予約方式なので再予約をやめてしまえば暗黙的に自動的にループが停止します。
注釈
うううわわわぁぁぁ・・・!setalermはsetalarmのタイポなのですがすでに私のレガシーが大量にあるので修正版も併せて置いておくので勘弁して。
タイポ修正版ライブラリ:
ベーシック版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timerEx.js
モジュール版
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timerEx.module.js
🌐 enterFrame との比較
JavaScriptで3Dを扱う場合、X3DOMの enterFrame のような描画イベントを利用する方法もあります。
例えば、
function GLoop(){
if(x3domRuntime == undefined){
x3domRuntime =
document.getElementById('x3danm').runtime;
if(x3domRuntime)
x3domRuntime.enterFrame = reDraw;
}
else {
x3domRuntime.triggerRedraw();
}
setTimeout(GLoop,50);
}
のような構造です。
enterFrame は、
描画イベントに合わせて処理を実行する
という用途には非常に便利です。
一方、N6LTimerManは、
アプリケーション側から周期タスク全体を管理する
ことを目的としています。
したがって、どちらが優れているというより、目的が異なります。
🆚 enterFrame / requestAnimationFrame と N6LTimerMan
| 項目 | enterFrame / requestAnimationFrame | N6LTimerMan |
|---|---|---|
| 主な目的 | 描画タイミングとの同期 | 周期タスクの統括 |
| ループの主導権 | ブラウザ・描画側 | アプリケーション側 |
| 描画同期 | ◎ | ○ |
| タスクごとの周期管理 | △ | ◎ |
| 描画と無関係な周期処理 | △ | ◎ |
| 複数周期タスクの統合 | 自前実装 | 標準機能 |
| 可変周期タスク | 実装可能 | 自然に扱える |
| CPU負荷の調整 | △ | 周期によって調整可能 |
| 再入・多重起動対策 | アプリ側で実装 | N6LTimerMan側で管理 |
🎨 requestAnimationFrame は不要なのか?
もちろん、そういう意味ではありません。
requestAnimationFrame() は、
「ブラウザの描画タイミングに合わせて処理したい」
という用途に非常に適しています。
例えば、
function Loop(time) {
update(time);
renderer.render(scene, camera);
requestAnimationFrame(Loop);
}
という構造は、描画中心のアプリケーションでは非常に自然です。
一方で、
物理 10ms
AI 100ms
センサー 250ms
ログ 1000ms
描画 50ms
のように、描画とは異なる周期の処理を多数管理したい場合には、描画イベントを中心に設計する必要はありません。
そこでN6LTimerManが選択肢になります。
⚠ requestAnimationFrame が重い場合の考え方
requestAnimationFrame() 自体が必ずCPU負荷を高くするわけではありません。
実際の負荷は、
JavaScript処理
+
Three.jsなどの描画処理
+
ブラウザのレンダリング
+
GPU処理
などの合計によって決まります。
私の環境では、実際のアプリケーションで、
requestAnimationFrame
↓
CPU使用率 約30%
N6LTimerMan
↓
CPU使用率 約15%
となったケースがありました。
これは、
N6LTimerManの方が常に軽い
という意味ではありません。
そのアプリケーションでは、描画・更新周期を下げることによって処理回数そのものを減らした結果です。
したがって、
CPU負荷を下げたい場合は、必要な更新頻度を見直し、処理周期を適切に設定する
というのが本質です。
⚠ 軽いループから重い処理を回すと「宿題」が溜まる
イベント駆動型のループでは、呼び出し側の周期と実際に行う処理量のバランスが重要です。
例えば、
描画イベント
↓
重いシミュレーション処理
↓
次の描画イベント
↓
また重いシミュレーション処理
という構造で、1回の処理に必要な時間がイベント周期を超えてしまうと、
次の処理までに消化できない仕事が残っていきます。
いわば、
軽いループで重い処理を回すと「宿題」が溜まる
という状態です。
例えば50ms周期で呼び出される処理に80ms必要なら、
50ms → 処理開始
130ms → 処理終了
100ms → 本来は次の処理開始予定
となり、処理が予定周期に追いつきません。
もちろん JavaScript の通常のコールバックは、
同時に複数実行されるわけではありません。
しかし、イベントやタイマーの処理が遅延し、
結果として処理待ちやフレーム遅延が蓄積することがあります。
重要なのは、
「呼び出し周期が短いこと」と「処理を高速に完了できること」は別問題
という点です。
そのため、重いシミュレーション処理を常に描画周期に従わせるのではなく、
描画 → 必要な周期で実行
物理計算 → 独自の周期で実行
AI → 独自の周期で実行
データ処理 → 独自の周期で実行
のように、処理ごとに適切な周期を設定する方が扱いやすい場合があります。
N6LTimerMan では、これらを一つのタイマー管理機構で調整できます。
つまり問題は、
requestAnimationFrame が遅い
ということではありません。
描画周期を、描画以外のすべての処理の実行周期として利用することが
必ずしも適切ではない
ということです。
requestAnimationFrame は「次の描画に合わせて処理する」という目的には非常に適しています。
一方、
処理ごとに異なる周期を持たせ、それらを一つのループで調整したい
のであれば、別のタイマー管理方式が必要になります。
🧠 N6LTimerMan の本当の特徴
N6LTimerManの特徴は、
JavaScriptをマルチスレッド化することではありません。
そうではなく、
JavaScriptのシングルスレッドという前提を維持したまま、複数の周期タスクを1本の管理ループで統括すること
です。
例えば、
N6LTimerMan
│
TMUpdate
│
┌──────────┼──────────┐
↓ ↓ ↓
物理 AI 描画
10ms 100ms 50ms
│ │ │
└──────────┴──────────┘
↓
次の更新
となります。
これはゲームエンジンの、
WinMain
↓
updateFrame
↓
各種処理
↓
次のフレーム
という思想に近い構造です。
🎯 まとめ
N6LTimerManの目的は、JavaScriptに本当のマルチスレッドを導入することではありません。
目的は、
- JavaScriptのシングルスレッド性を維持する
- 複数の周期タスクを独立して管理する
- 1本の管理ループから各タスクを統括する
- タスクごとに異なる周期を設定する
- 可変周期タスクにも対応する
- 描画周期と処理周期を分離する
- 再入・多重起動を管理する
- 必要な更新頻度に応じてCPU負荷を調整する
ことです。
つまり、
「JavaScriptをマルチスレッドにする」のではなく、
「シングルスレッドのまま、WinMain+updateFrame型の処理統括を実現する」
という考え方です。
🧩 N6Lの設計思想
N6LTimerManは、単独で巨大なゲームエンジンを作ることを目的としていません。
例えば、
N6LDate
↓
時間・暦を担当
N6LRingBuffer
↓
履歴を担当
N6LManagedClass
↓
オブジェクト管理を担当
N6LTimerMan
↓
周期処理を担当
というように、それぞれが専門の仕事を担当します。
必要なものを組み合わせることで、
物理シミュレーション
+
履歴保存
+
タイマー管理
+
3D描画
といったシステムを構築できます。
万能な巨大システムを1つ作るのではなく、役割の明確な小さな部品を組み合わせる。
これがN6Lライブラリの基本的な設計思想です。
📚 関連ページ
デモ:
参考:
https://nas6.net/test3jsexp.htm
https://nas6.net/ManagedTimerManager.htm
ライブラリ:
https://github.com/NAS6mixfoolv/NAS6LIB/blob/main/javascripts/nas6lib/timer.js
作者
GitHub: https://github.com/NAS6mixfoolv
X(旧Twitter): https://x.com/NAS6_oxo
作者HP: https://nas6.net