[CS講座 #08] クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む

[CS講座 #08] クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む

はじめに

CPUコアを作り、バスを繋ぎ、メモリバンキングやHLEでゲームROMが起動するようになったとき、最後に立ちはだかる最大の壁——それが「時間の制御(クロック同期)」です。

どれほど精密なエミュレーターを組んでも、クロック制御が甘ければゲームは数十倍の超スピードで爆走するか、あるいはフレームごとにガタガタと引っかかるぎこちない動きになってしまいます。

Z80(3.58MHz)やMOS 6502(1.023MHz)といった物理ハードウェアが放つ「1秒間に数百万回」という厳密なパルスを、単一スレッドかつイベント駆動であるWebブラウザ(JavaScript)の上でどのように再現し、滑らかな60fps描画と音切れのないサウンドを両立させるのか?

【実践編】の締めくくりとなる第8回は、Webエミュレーターの心臓部である「メインループとクロック同期メカニズム」を解き明かします。

前回の記事

Z80 EMULATOR PROJECT

6502 EMULATOR PROJECT

1. なぜブラウザ上の時間はズレるのか?:JSイベントループとタイマーの現実

JavaScriptでループ処理を作ろうとした際、初めに思いつくのが setInterval や setTimeout です。しかし、これらをエミュレーターのメインループに使用することはできません。

従来のタイマー(setInterval)が使えない理由

  1. 精度の低さとジッター(ゆらぎ): ブラウザの setInterval は数ミリ秒〜十数ミリ秒単位の大きな誤差(ジッター)が発生します。
  2. バックグラウンド時のブラウザ制限: タブがバックグラウンドに移動すると、省電力のためにタイマーの実行頻度が 1秒に1回(1000ms)程度まで強制的に落とされます。
  3. メインスレッドのブロック: 他のDOM操作やスタイル計算が入るとタイマーの発火が遅延し、時間が等速で進みません。

物理クロックと1フレームの「目標消費T-State」

エミュレーターでは、時間を「秒」ではなく「CPUが実行したクロック数(T-State / Cycle)」で管理します。

システムCPUクロックリフレッシュレート1フレームあたりの目標クロック数
MSX1 (Z80)3,579,545 Hz59.92 Hz約 59,736 T-States
Game Gear (Z80)3,579,545 Hz59.92 Hz約 59,736 T-States
Apple II (6502)1,023,000 Hz60.00 Hz約 17,050 Cycles
Commodore 64 (6510)1,022,727 Hz50.12 Hz (PAL)約 20,405 Cycles

1秒間(1000ms)を 60fps(1フレーム = 約16.66ms)で分割した際、「1フレームの間にCPUを何クロック分進めるべきか」を計算し、その目標クロック分だけ step() を回すのがメインループの基本思想です。

 [1フレーム (約16.66ms) の実行フロー]
 +-------------------------------------------------------------------+
 | 1. CPUをステップ実行 (累積 T-State >= 59,736 になるまで loop)     |
 | 2. 画面レンダリング (Canvas / WebGL への描画)                     |
 | 3. オーディオバッファへ音声サンプルを送信                         |
 +-------------------------------------------------------------------+
                               |
                               v (次の描画フレームを待つ: requestAnimationFrame)

2. 同期制御の2大アプローチ:rAF主導 vs AudioWorklet主導

メインループを駆動させる手法には、大きく分けて2つのアプローチが存在します。

A. requestAnimationFrame(rAF)主導(画面同期型)

ブラウザの画面更新タイミング(通常60Hz)に合わせて発火する requestAnimationFrame をループの軸にする最も標準的な手法です。

  • メリット: ディスプレイの表示更新タイミングと完全に同期するため、画面のチラつき(ティアリング)が発生せず滑らかな描画が得られます。
  • 罠(高リフレッシュレートモニター問題): ゲーミングモニターなどの 120Hz / 144Hz / 240Hz 環境でそのまま動かすと、requestAnimationFrame が1秒間に120回〜240回呼ばれてしまい、ゲームが2倍〜4倍の超高速で動作するバグが発生します。

対策:経過時間(performance.now())によるデルタタイム補正

ディスプレイのリフレッシュレートに依存せず実時間を刻むため、高精度タイムスタンプ performance.now() で前回のコールからの経過時間(ミリ秒)を計測し、実際に経過した時間分だけCPUクロックを加算・消化します。


B. AudioWorklet サンプルバッファ主導(音声同期型)

より完璧な実機同期を目指す上級テクニックが、「Web Audio API(AudioWorklet)の音声消費ペースをタイムベースにする」 という手法です。

人間の目(映像)は数ミリ秒の遅延や1〜2コマのコマ落ちに鈍感ですが、人間の耳(音声)は数ミリ秒のバッファ空き(アンダーラン)による「プチッ」というノイズに極めて敏感です。

 [AudioWorklet同期のメカニズム]
 AudioWorklet (スピーカー) <--- [ 音声バッファ (リングバッファ) ] <--- Main Loop (CPU)

  * 音声バッファが減ってきたら -> CPUを多めに回して音声を補充
  * 音声バッファが溜まってきたら -> CPUの実行を抑えて同期を保つ

サウンドカードのサンプリングレート(例: 48,000Hz)は物理水晶振動子に基づいているため、音声バッファの残量を一定に保つようにメインループをフィードバック制御することで、音切れゼロかつ完璧な等速実行が達成されます。


3. 処理落ち(Lag)とフレームスキップのアルゴリズム

モバイル端末や省電力モードなどで、ブラウザの処理能力が追いつかず1フレーム(16.66ms)以内にCPU計算と画面描画が終わらない場合があります。

この時、愚直に描画を続けているとゲームの進行速度自体が遅くなり(処理落ち)、音切れや操作遅延が発生します。

これを防ぐのが フレームスキップ(Frame Skip) です。

  通常時  : [CPU計算 + 重いVRAM描画] -> [CPU計算 + 重いVRAM描画]  (60fps維持)
 処理遅延時: [CPU計算 + 重いVRAM描画] -> [CPU計算 (描画スキップ!)] (ゲーム速度を維持)
  1. CPUと音源の計算だけはスキップせず100%正確に行う(ゲーム内部の時間軸を崩さない)。
  2. 時間が遅れている場合のみ、最も負荷の高い「CanvasへのVRAMレンダリング」だけをパスする。
  3. 連鎖的な遅延による「スパイラル・オブ・デス(無限追いつきループ)」を防ぐため、連続スキップ数は最大3〜5フレーム程度に制限(Cap)する。

4. エミュレーターでの実装(JavaScript)

高精度タイムスタンプ performance.now()、デルタタイム蓄積、およびフレームスキップ制御を組み込んだ、実践的なエミュレーターメインループクラスの実装例です。

クロック同期メインループの実装コード

export class ClockSyncedMainLoop {
  constructor(options) {
    this.cpu = options.cpu;             // CPUコアインスタンス
    this.renderer = options.renderer;   // 画面描画オブジェクト
    this.audio = options.audio;         // 音源処理オブジェクト

    // システム仕様の定義
    this.cpuClockHz = options.cpuClockHz || 3579545; // 3.58MHz (Z80)
    this.targetFps = options.targetFps || 59.92;      // ターゲットFPS

    // 1ミリ秒あたりの目標CPUクロック数
    this.cyclesPerMs = this.cpuClockHz / 1000;

    // 時間管理用状態変数
    this.lastTime = 0;
    this.accumulatedCycles = 0;
    this.isRunning = false;

    // フレームスキップ制御
    this.maxFrameSkip = 3;
    this.animationFrameId = null;
  }

  start() {
    if (this.isRunning) return;
    this.isRunning = true;
    this.lastTime = performance.now();
    this.accumulatedCycles = 0;

    // ループ開始
    this.loop = this.loop.bind(this);
    this.animationFrameId = requestAnimationFrame(this.loop);
  }

  stop() {
    this.isRunning = false;
    if (this.animationFrameId) {
      cancelAnimationFrame(this.animationFrameId);
    }
  }

  loop(currentTime) {
    if (!this.isRunning) return;

    // 1. 前回のフレームからの経過時間(ミリ秒)を計算
    let deltaMs = currentTime - this.lastTime;
    this.lastTime = currentTime;

    // バックグラウンド復帰時などの異常な巨大デルタ(例: 1000ms以上)をカット
    if (deltaMs > 100) {
      deltaMs = 16.66; // 1フレーム分に丸めて爆発を防ぐ
    }

    // 2. 経過時間に応じた「実行すべき目標クロック数」を蓄積
    this.accumulatedCycles += deltaMs * this.cyclesPerMs;

    let skippedFrames = 0;

    // 3. 蓄積されたクロック数が1フレーム分を超えている間、CPUを進める
    const cyclesPerFrame = this.cpuClockHz / this.targetFps;

    while (this.accumulatedCycles >= cyclesPerFrame) {
      let frameCycles = 0;

      // 1フレーム分(約59,736 T-State)のCPU命令を実行
      while (frameCycles < cyclesPerFrame) {
        const cycles = this.cpu.step();
        frameCycles += cycles;

        // 音声サンプルの生成とストリーミング
        if (this.audio) {
          this.audio.step(cycles);
        }
      }

      // 蓄積クロックから1フレーム分を減算
      this.accumulatedCycles -= cyclesPerFrame;

      // 4. フレームスキップ判定
      // 蓄積クロックがまだ残っており、スキップ上限に達していなければ描画をスキップ
      if (this.accumulatedCycles >= cyclesPerFrame && skippedFrames < this.maxFrameSkip) {
        skippedFrames++;
      } else {
        // 通常描画:Canvas / WebGL 画面レンダリング実行
        if (this.renderer) {
          this.renderer.render();
        }
        break; // 描画を行ったらループを抜けて次の rAF へ
      }
    }

    // 次の画面更新タイミングへ登録
    this.animationFrameId = requestAnimationFrame(this.loop);
  }
}

システム組み込み例

// エミュレーターのメインループ初期化
const mainLoop = new ClockSyncedMainLoop({
  cpu: z80CpuCore,
  renderer: canvasRenderer,
  audio: psgAudioWorklet,
  cpuClockHz: 3579545, // MSX / SG-1000 / GG の 3.58MHz
  targetFps: 59.92
});

// ゲーム起動
mainLoop.start();

このメインループ設計により、60Hzディスプレイはもちろん、144Hz等の高リフレッシュレート環境でも速度が狂わず、低スペック環境での処理落ち時にもゲーム進行速度を一定に維持した快適なエミュレーションが実現します。


まとめ

  • setInterval の限界: 時間精度が低くジッターが大きいため、Webエミュレーターのメインループには不適。
  • requestAnimationFrame + デルタタイム補正: 画面描画の滑らかさを保ちつつ、高リフレッシュレートモニター(120Hz/144Hz)での倍速挙動を防止する基本構造。
  • AudioWorklet同期: 音声サンプルの消費ペースをタイムベースにすることで、音切れ(プチノイズ)のない完璧なリアルタイム同期を実現する。
  • フレームスキップ: 処理能力が追いつかない場面でも、CPU/音声計算を優先して画面描画のみをパスすることで、ゲームの進行速度を等速に維持する。

【実践編】の完結と次のステップ

【基礎編】(第1回〜第4回)で学び取ったCPUの内部構造・バス通信・命令解釈の理論。 そして【実践編】(第5回〜第8回)で構築した割り込み・メモリバンキング・HLE・クロック同期の実装技術。

これらすべてのパズルピースが組み合わさることで、Webブラウザという現代のサンドボックス環境の上に、数十年前に作られた名機たちの命が完全に蘇ります。

レトロハードの技術探求は、基礎的なコンピュータサイエンスの美しさと創意工夫に溢れています。


シリーズ全目次

  • 【基礎編】

  • 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?

  • 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?

  • 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ

  • 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く

  • 【実践編】

  • 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み

  • 第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む

  • 第7回:高級言語によるHLEとレガシーの再現 — BIOS不要起動(Direct Boot)のからくり

  • 第8回:クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む(本記事)