[CS講座 #09] 走査線とVDP描画エンジン — タイルマップとスプライトの画面レンダリング
はじめに
【基礎編】(第1回〜第4回)でCPUのロジックと命令 execution の基礎を固め、【実践編】(第5回〜第8回)で割り込み・メモリバンキング・HLE・クロック同期といったシステム構築手法を学びました。
いよいよ本講座は【応用編】へと突入します。
【応用編】の第1弾となる第9回テーマは、CPUから画像出力を一手に引き受ける映像処理専門プロセッサ「VDP(Video Display Processor)」とグラフィック描画エンジンの仕組みです。
広大なマップを表示する「タイルマップ(BG)」、画面上を自由に動き回るキャラクターを描く「スプライト」、そしてCRT(ブラウン管)ディスプレイの物理特性に合わせた「走査線(Scanline)レンダリング」のカラクリを、TMS9918Aやゲームギア Mode 4 を題材に解き明かしていきます。
前回の記事
[CS講座 #08] クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む // PROTOCOL.LAIN
JavaScriptのイベントループにおけるタイマー精度の問題、Z80/6502のCPUクロック(T-State/Cycle)と60fpsフレーム同期の原理、AudioWorkletのサンプルバッファ主導クロック制御、フレームスキップと処理落ち対策コードを徹底解説。
lain-lab.comZ80 EMULATOR PROJECT
Z80 EMULATOR PROJECT
フルスクラッチ JavaScript Z80 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com6502 EMULATOR PROJECT
6502 EMULATOR PROJECT
フルスクラッチ JavaScript MOS 6502 コアと、その上に構築されたレトロシステムエミュレーター群
lain-lab.com1. CRTの走査線(Scanline)とVDPの非同期非同期モデル
現代の液晶ディスプレイは画面全体をフレームバッファとして保持し、一括で更新できます。しかし、昭和〜平成初期のレトロハードが接続されていたCRT(ブラウン管)テレビは、電子銃から放たれた電子線が画面の「左から右」「上から下」へと高速で1行ずつ光を走らせる走査線(Scanline)構造をとっていました。
(画面の左上からスタート)
----> Scanline 0 --------------------------+
+<------------------------------------------+ (H-Blank: 水平帰線)
----> Scanline 1 --------------------------+
...
----> Scanline 191 -------------------------+
| (V-Blank: 垂直帰線)
^===========================================+ (画面最下部から左上へ移動)
H-Blank と V-Blank
- Scanline(走査線): 画面上に描写される水平方向の1行。
- H-Blank(水平帰線期間): 電子線が1行を描き終えて、次の行の左端へ移動する極小の隙間時間。
- V-Blank(垂直帰線期間): 電子線が画面の一番下(例: 192行目)まで達し、再び一番上の左端へ戻るまでの移動期間。第5回で解説した「V-Blank割り込み」はこのタイミングで発火します。
CPUとVDPの分離
Z80などの8ビットCPUは、毎フレーム何万ピクセルもの描画を計算する処理能力を持っていません。そのため、画面描画は独立した専用プロセッサである VDP(Video Display Processor) と、CPUメインメモリとは隔離された VRAM(Video RAM) に任せます。
CPUは描画のたびにドットを直接書くのではなく、Port-Mapped I/O(第2回参照)などを経由して「どの画像データをどこに並べるか」という指示データ(VRAM)を書き込むだけで済みます。
2. VRAMの内部構造:パターンテーブルとネームテーブル
8ビット〜16ビット時代、数キロバイトから数十キロバイト(例: 16KB)程度の極小VRAMで広大かつ色鮮やかなゲーム画面を表現するために考案されたのが「タイルマップ(BG)」構造です。
[1. パターンテーブル (8x8ドット画像データ)]
ID 0x01: [壁] ID 0x02: [草] ID 0x03: [宝箱]
[2. ネームテーブル (画面グリッド配置図: 32x24タイル)]
+-----------------------------------+
| 0x01 | 0x01 | 0x01 | 0x01 | 0x01 | <- 画面上部はすべて「壁」
+------+------+------+------+------+
| 0x02 | 0x02 | 0x03 | 0x02 | 0x02 | <- 「草」の中に「宝箱」
+-----------------------------------+
A. パターンテーブル(Pattern Table / Tile Data)
ピクセルの最小グラフィック単位(タイル/パターン)の画像バイナリを保持する領域です。
- 1bpp (1ビット/ピクセル): モノクロ。1バイトで8ピクセルを表現。
- 2bpp / 4bpp (4ビット/ピクセル): 1ピクセルあたり4ビット(色)のカラーインデックスを表現。4つの「プレーン(Plane)」に分散して保持される形式が一般的です。
B. ネームテーブル(Name Table / Tile Map)
画面上のグリッド(例: 横32 × 縦24 = 768タイル)のどこに、どのパターンID(0〜255等)を配置するかを指定する「地図」となる配列データです。
C. パレットRAM(CRAM)
パターンデータ内の数値(0 〜 15)は直接の色(RGB)ではなく、「カラーパレットの番号」を指しています。
- TMS9918A (MSX1/SG-1000): パレットはハードウェア固定の16色。
- Master System / Game Gear (GG Mode 4): 64色中16色(GGは4096色中32色)を選べる可変パレットRAM(CRAM)を搭載し、カラーレジスタ書き換えで鮮やかなグラフィックを実現します。
3. スプライト(Sprite)の動作原理とハードウェア制約
背景(ネームテーブル)とは別に、プレイヤーキャラクターや敵、弾などの「画面内をドット単位で自由に動き回るオブジェクト」を表示する仕組みが スプライト(Sprite) です。
スプライト属性テーブル(SAT: Sprite Attribute Table)
VRAM内の特定アドレスに配置され、各スプライトのステータス(位置と見た目)を保持します。
[スプライト1つのデータ構造 (4バイト)]
1. Y座標 : 画面上の縦位置 (0 〜 255)
2. X座標 : 画面上の横位置 (0 〜 255)
3. パターン番号: 表示する 8x8 タイルのID
4. 属性/カラー: 優先度(BGの手前/奥)、反転フラグ、パレット番号
1走査線あたりのスプライト表示制限(8個制限)
ハードウェアVDPの内部には、スプライト描画用のラインバッファ回路が存在します。
回路の規模(ゲート数)を抑えるため、「1つの走査線上(水平1行)に並べられるスプライトの数には物理的な制限がある」 という制約が存在します。
- TMS9918A: 1走査線あたり最大 4個。
- Master System / Game Gear: 1走査線あたり最大 8個。
1行に9個以上のスプライトが重なった場合、9個目以降のスプライトは描画されず画面から消えてしまいます。レトロゲームでキャラクターが点滅(チラつき)するのは、ゲームソフト側が優先度の低いスプライトを1フレームごとに交互に非表示・表示させて画面から完全に消滅するのを防ぐ工夫をしていたためです。
スプライト衝突判定(Collision Detection)
VDPは、スプライト同士の「透明でないピクセル」が画面上で重なった瞬間をハードウェアレベルで検知し、ステータスレジスタの 衝突フラグ(Collision Flag) を 1 に立てる機能を備えています。
CPUは毎フレームこのフラグを確認することで、複雑な当たり判定計算を行うことなく「弾が自機に当たったか」を判定できました。
4. エミュレーターでの実装(JavaScript)
エミュレーターで描画エンジンを構築する場合、画面全体を一度に書く「フレームベース描画」ではなく、1行ずつ走査線を追ってレンダリングする スキャンライン・レンダラー(Scanline-based Renderer) を構築します。
これにより、ゲーム中に走査線の途中でパレットやスクロール位置を書き換える「ラスタースクロール」などの特殊技法も正確に再現可能になります。
スキャンライン単位のVDP描画エンジンの実装コード
export class VDP9918A {
constructor(canvasContext) {
this.ctx = canvasContext;
// VRAM (16KB)
this.vram = new Uint8Array(16384);
// VDP レジスタ (8バイト)
this.registers = new Uint8Array(8);
// 画面解像度 (256 x 192)
this.width = 256;
this.height = 192;
// Canvas描画用バッファ (32bit RGBA ピクセル配列)
this.imageData = this.ctx.createImageData(this.width, this.height);
this.pixels = new Uint32Array(this.imageData.data.buffer);
// 固定16色パレット (TMS9918A RGBテーブル: 0xAABBGGRR)
this.palette = new Uint32Array([
0x00000000, 0xff000000, 0xff21c847, 0xff5cd96a,
0xffe45554, 0xffff796d, 0xff2552d4, 0xff52dae6,
0xff2555e6, 0xff527dff, 0xffc6d3d4, 0xff41cecd,
0xff21b222, 0xffc65ec8, 0xffcccccc, 0xffffffff
]);
}
// 1行(指定した Scanline)をレンダリング
renderScanline(scanline) {
if (scanline < 0 || scanline >= 192) return;
const lineOffset = scanline * this.width;
// --- 1. 背景 (BG / タイルマップ) の描画 ---
const nameTableBase = (this.registers[2] & 0x0f) << 10;
const patternTableBase = (this.registers[4] & 0x07) << 20;
const tileY = Math.floor(scanline / 8);
const fineY = scanline % 8; // タイル内でのYオフセット(0~7)
for (let tileX = 0; tileX < 32; tileX++) {
// ネームテーブルからパターンIDを取得
const nameAddr = nameTableBase + (tileY * 32) + tileX;
const patternId = this.vram[nameAddr];
// パターンテーブルから 1bpp フォント/グラフィックの1行分(1バイト)を取得
const patternAddr = patternTableBase + (patternId * 8) + fineY;
const patternByte = this.vram[patternAddr];
// パターンに対応する前景色/背景色を取得 (カラーテーブル)
const colorByte = this.vram[(this.registers[3] << 6) + (patternId >> 3)];
const fgColor = this.palette[colorByte >> 4];
const bgColor = this.palette[colorByte & 0x0f];
// 8ピクセル分をバッファへ展開
for (let bit = 0; bit < 8; bit++) {
const x = (tileX * 8) + bit;
// ビットが立っていれば前景色、立っていなければ背景色
const isSet = (patternByte & (0x80 >> bit)) !== 0;
this.pixels[lineOffset + x] = isSet ? fgColor : bgColor;
}
}
// --- 2. スプライトの描画 (1ライン最大4個制限) ---
this.renderSpritesForScanline(scanline);
}
// スプライト描画ロジック
renderSpritesForScanline(scanline) {
const satBase = (this.registers[5] & 0x7f) << 7;
const sgBase = (this.registers[6] & 0x07) << 11;
let spritesOnLine = 0;
for (let i = 0; i < 32; i++) {
const spriteAddr = satBase + (i * 4);
let y = this.vram[spriteAddr];
// 0xd0 (208) はスプライトテーブルの終端マーク
if (y === 0xd0) break;
// 画面上の実際のY座標補正
if (y > 224) y -= 256;
y += 1;
// この走査線とスプライト(高さ8px)が交差しているか判定
if (scanline >= y && scanline < (y + 8)) {
spritesOnLine++;
// 1走査線あたり 4個 制限を超えたら描画を打ち切り (5番目フラグ等をセット)
if (spritesOnLine > 4) {
this.registers[7] |= 0x40; // 5th Sprite Flag
break;
}
const x = this.vram[spriteAddr + 1];
const patternId = this.vram[spriteAddr + 2];
const colorAttr = this.vram[spriteAddr + 3];
const color = this.palette[colorAttr & 0x0f];
const fineY = scanline - y;
const patternByte = this.vram[sgBase + (patternId * 8) + fineY];
for (let bit = 0; bit < 8; bit++) {
const pixelX = x + bit;
if (pixelX >= 256) break;
const isSet = (patternByte & (0x80 >> bit)) !== 0;
if (isSet && (colorAttr & 0x0f) !== 0) { // 透明色(0)でなければ描画
this.pixels[(scanline * this.width) + pixelX] = color;
}
}
}
}
}
// 1フレーム完了時に Canvas へ描画反映
flushFrame() {
this.ctx.putImageData(this.imageData, 0, 0);
}
}
ゲームギア(Mode 4)の画面クロップ(160x144)テクニック
セガのゲームギア(Game Gear)は、セガ・マスターシステム(256x192)と全く同じVDPコア(Mode 4)を搭載しています。
実機の液晶画面が 160 × 144 ピクセルと小さいため、VDPは内部的に 256 × 192 解像度でVRAMを生成し、エミュレーター側で中央の 160 × 144 領域だけを切り出してCanvasへ転送(画面クロップ)する処理を行います。
// Game Gear 用の画面クロップ (中央 160x144 を抽出)
cropGameGearScreen(vdpPixels, canvasContext) {
const offsetX = 48; // (256 - 160) / 2
const offsetY = 24; // (192 - 144) / 2
const ggImageData = canvasContext.createImageData(160, 144);
const ggPixels = new Uint32Array(ggImageData.data.buffer);
for (let y = 0; y < 144; y++) {
for (let x = 0; x < 160; x++) {
const srcIdx = ((y + offsetY) * 256) + (x + offsetX);
const dstIdx = (y * 160) + x;
ggPixels[dstIdx] = vdpPixels[srcIdx];
}
}
canvasContext.putImageData(ggImageData, 0, 0);
}
このような仕組みを理解することで、異なる解像度や画面仕様を持つ複数ハードウェアのVDPエミュレーションを、単一の描画コア構造の上に綺麗に集約することができます。
まとめ
- CRT(ブラウン管)の走査線(Scanline)構造 に合わせ、VDPは H-Blank / V-Blank の周期で画面を形成する。
- タイルマップ(BG) は、グラフィック最小単位の パターンテーブル と、配置図である ネームテーブル を組み合わせることでVRAM容量を劇的に節約する。
- スプライト は独立した座標をもつ描画オブジェクトだが、ハードウェア回路の制約上 1走査線あたりの同時表示数(4個〜8個)に上限が存在する。
- エミュレーターでは 1行ずつ描画する スキャンライン・レンダラー を実装することで、ラスタースクロール等の特殊表現や正確なスプライト衝突判定を再現できる。
次回は、画面に続いて「音」を宿す低レイヤー音源合成技術、「第10回:音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる」をお届けします。
シリーズ全目次
- 【基礎編】
- 第1回:レジスタとフラグの正体 — なぜ8ビットで255までしか扱えないのか?[cite: 9]
- 第2回:メモリ空間とバス制御 — CPUはどうやって外部と会話するのか?
- 第3回:Fetch-Decode-Execute — CPUが命を宿す無限ループ
- 第4回:ビット演算の極意 — 複雑な命令セットを美しく捌く
- 【実践編】
- 第5回:割り込み(Interrupt)の仕組み — 非同期イベントを検知するハードウェアの割り込み
- 第6回:メモリバンキング(Bank Switching) — 64KBの壁を超えて巨大ROMを読み込む
- 第7回:高級言語によるHLEとレガシーの再現 — BIOS不要起動(Direct Boot)のからくり
- 第8回:クロック同期とメインループ — Webブラウザ上で正確な周波数を刻む
- 【応用編】
- 第9回:走査線とVDP描画エンジン — タイルマップとスプライトの画面レンダリング(本記事)
- 第10回:音源チップの波形合成 — レトロゲーム音をWeb Audio APIで発声させる(次回)
- 第11回:マルチCPUエミュレーション — 複数のZ80を同期させて動かす(アーケード基板の世界)
- 第12回:テスト駆動エミュレーター開発 — ZEXDOC全通へのデバッグ格闘記