[Computer] 3.58MHzの箱庭:Z80と8ビット時代の「計算しない」極限の最適化美学

[Computer] 3.58MHzの箱庭:Z80と8ビット時代の「計算しない」極限の最適化美学

概要

現代の数GHzのCPUから見れば「息をするのも苦しい」8ビット機の制約。先人たちはその壁を前に、いかにして数学的対称性やメモリ空間をハックし、描画を成立させていたのか。LUTやビット演算が織りなす極限の最適化を紐解く。

1. 3.58MHzの重力と、FPU(浮動小数点演算器)なき世界

静寂の中でAstroの開発サーバーを立ち上げ、モニタの片隅でThree.jsが描画する数百万のポリゴンを眺める。現代の私たちは、JavaScriptという高水準言語から、GPUの持つ数千のコアと強大な浮動小数点演算(FPU)の恩恵を、まるで水道水のように当たり前に享受しています。

しかし、時計の針を1980年代——パーソナルコンピューターの黎明期に巻き戻すと、そこにあるのは「息をするのも苦しい」ほどの極限状態でした。

当時の主役であったZ80などの8ビット・マイクロプロセッサのクロック周波数は、わずか3.58MHz前後。現代の数GHz(数十億Hz)のCPUと比べれば、1000分の1以下の速度に過ぎません。搭載できるメインメモリも、メガやギガではなく、たかだか16KB〜64KB(キロバイト)という箱庭のような世界でした。

「計算」が許されない計算機

3Dグラフィックスであれ、2Dのゲームであれ、画面上に図形を描画し、動かすためには「数学」が必要です。しかし当時のプログラマーにとって、何より絶望的だった壁があります。それは、CPUに小数を計算する専用回路(FPU)が存在しなかったことです。

それどころか、Z80の基本命令セットには「掛け算(MUL)」や「割り算(DIV)」すら存在しませんでした。足し算(ADD)と引き算(SUB)しか持たない脳細胞に対して、画面に滑らかな円を描くための sin()cos() をソフトウェア演算(マクローリン展開など)でまともに計算させようものなら、たった1ピクセルの座標を求めるだけで数千クロックを浪費し、フレームレートは完全に崩壊してしまいます。

計算機(コンピューター)でありながら、描画ループの中ではまともに「計算」させることが許されない。この絶対的な重力とも言える制約を前に、当時のプログラマーたちはどうしたのか。

計算を放棄し、パズルを解く

彼らは計算そのものを放棄し、アーキテクチャの仕様の隙間を縫うような泥臭いハックへと足を踏み入れました。

数学的な対称性を利用して処理を削り落とし、掛け算をデータの並べ替えに置換し、「空間(メモリ)を消費して時間(CPUサイクル)を買う」という等価交換を極限まで突き詰めたのです。

本稿では、現代の優秀なコンパイラやフレームワークがブラックボックスの中に隠してしまった「描画の最底辺」の歴史を遡ります。誰も見ていない個人的なアーカイブの片隅で、1バイトのメモリと1クロックの実行時間に命を懸けた、LUT(事前計算テーブル)やビット演算が織りなす美しい最適化の箱庭を覗いてみましょう。

2. 空間(メモリ)を消費して時間(CPU)を買う — LUTの魔法

「計算するな、メモリから引け」

これは当時のリアルタイム描画において、絶対の正義とされた鉄則です。 毎フレーム数百〜数千回も呼ばれる描画ループの中で、まともに演算器を回す余裕はありません。そこで先人たちが多用したのがLUT(Look-Up Table:事前計算テーブル)という手法でした。

例えば、オブジェクトを回転させるための三角関数(サイン・コサイン)が必要だとします。 プログラムの起動時(あるいはゲームのロード時)の、まだ処理落ちを気にする必要がない時間帯に、0度から359度までの答えをあらかじめ計算し、配列としてメインメモリ上にズラリとベタ書き(キャッシュ)してしまうのです。

いざ描画ループが始まり「45度のサイン値が欲しい」となったら、計算は一切行いません。 ベースアドレス + 45 というメモリ上の特定の位置に直接アクセス(ポインタ参照)するだけです。どれほど複雑な数式であっても、この手法を用いれば計算の複雑度は常に「O(1)(定数時間)」——つまり、わずか数クロックのメモリアクセス時間へと圧縮されます。

これが「空間(メモリ)を消費して時間(CPUサイクル)を買う」という等価交換です。

対称性がもたらす「圧縮」の知恵

しかし、ここで8ビット機特有の「メモリの壁」が立ちはだかります。 仮に1つの角度の値を2バイトで表現したとして、360度分のデータを持てば720バイト。サイン波とコサイン波、さらに計算精度を上げるために小数点以下の細かい角度まで持とうとすれば、すぐに数キロバイトのメモリを食い潰してしまいます。全メモリが64KBしかない時代に、これは許容できない浪費でした。

そこで彼らは、数学の「対称性(シンメトリー)」を利用してLUTのサイズを限界まで削り落としました。

サイン波(正弦波)のグラフを思い浮かべてみてください。あの波の形は、実は「0度から90度」までのカーブの形状さえ分かっていれば、残りのすべての角度の値を再現できる構造を持っています。

プログラマーたちは、360度分のテーブルを用意するのをやめました。 メモリ上に保存するのは、たった「0度〜90度」までのデータ(1/4のサイズ)だけです。

では、90度以上の値が要求されたときはどうしたのか? 非常に簡素な if 文(アセンブリにおけるフラグ分岐)を用いて、以下のようにポインタの読み出し方を操作したのです。

  • 0〜90度: そのままテーブルの値を読む。
  • 91〜180度: テーブルを後ろから逆順に読む。(山を下るカーブを再現)
  • 181〜270度: そのままテーブルを読み、値の符号をマイナスに反転させる。(谷に下るカーブを再現)
  • 271〜359度: テーブルを逆順に読み、符号を反転させる。(谷から這い上がるカーブを再現)

メモリ空間を1/4に圧縮しながら、CPUの負荷は「インデックスの加減算」と「符号の反転」という、Z80が最も得意とする数クロックの極小処理だけを追加する。メモリとCPUリソースのギリギリのせめぎ合いの中で編み出された、芸術的とも言える妥協点でした。

現代のシェーダー開発において、私たちが何気なくテクスチャをサンプリングする行為も、本質的にはこの「巨大なLUTへのアクセス」と同義です。当時の彼らが1バイトのメモリを削り出すために駆使した泥臭いハックの精神は、実は今のGPUアーキテクチャの根底にも脈々と息づいているのです。

3. 掛け算を憎み、ビットシフトを愛する

「計算するな、メモリから引け」というLUTの魔法によって複雑な関数をねじ伏せたプログラマーたちですが、日常的な描画ループの中ではさらに根源的な問題が口を開けて待っていました。

それは、Z80には「掛け算(MUL)」と「割り算(DIV)」の命令が存在しないという事実です。

現代のプログラミング言語で a = b * 10; と書けば、コンパイラがよしなに1クロックで終わる機械語に変換してくれます。しかし、Z80の脳細胞(ALU)は「足し算(ADD)」と「引き算(SUB)」しか理解できません。 もしソフトウェア側で 15 * 10 を計算しようとすれば、「15を10回足すループ」を自前で書く羽目になります。これは数十から数百クロックを浪費する、リアルタイム描画においては文字通り「致命的」な処理の重さでした。

彼らは掛け算と割り算を深く憎みました。そして、その代替手段として「ビットシフト(<<, >>)」を熱狂的に愛したのです。

2の冪乗(べきじょう)という救済

私たちが普段使っている10進数の世界では、数値を左に1桁ズラす(末尾に0を足す)と、値は「10倍」になります。右にズラせば「1/10」です。 コンピューターの内部である2進数の世界でも、全く同じ法則が成り立ちます。数値を左に1ビットズラす(シフトする)と値は「2倍」になり、右にズラせば「1/2」になるのです。

Z80には、このビットを左右にズラす専用の命令(SLA, SRLなど)が備わっており、これはわずか数クロックで完了する極めて軽量な処理でした。

  • x * 2 をしたければ、x << 1(左に1ビットシフト)とする。
  • x * 8 をしたければ、x << 3(左に3ビットシフト)とする。
  • x / 16 をしたければ、x >> 4(右に4ビットシフト)とする。

プログラマーたちは、ループによる足し算を放棄し、ありとあらゆる乗算・除算をこの「ビットシフト」に置き換えました。

制約が形作る「ゲームデザイン」

しかし、ビットシフトで掛け算・割り算ができるのは「2の冪乗(2, 4, 8, 16, 32, 64…)」の数値に限定されます。* 10/ 3 といった中途半端な計算は、1回のシフトでは不可能です。

ここからが、この時代特有の最も面白いハックです。 彼らは計算を工夫するのではなく、「ゲームやシステムの仕様(デザイン)を、2の冪乗に合わせる」という逆転の発想に出ました。

  • なぜ、昔のRPGのマップチップ(タイル)は「16×16ピクセル」なのか?(座標の計算を >> 4 だけで済ませるため)
  • なぜ、キャラクターの移動速度やパラメーターには「8」や「32」といった数字が頻出するのか?
  • なぜ、テクスチャの解像度は「256×256」や「512×512」でなければならなかったのか?

私たちがレトロゲームや古いシステムに感じる「あの特有の手触りや法則性」の多くは、ただの偶然やデザイン上の好みではありません。それはすべて、「掛け算を憎み、ビットシフトを愛した」プログラマーたちが、Z80という非力なプロセッサのご機嫌を損ねないよう、世界(プログラム)の物理法則の方をアーキテクチャに屈服させた結果なのです。

4. ピクセルを諦め、ポインタ(文字)を動かす

LUTで計算を省き、ビットシフトで掛け算を回避しても、グラフィックスにおいて最も重い物理的なボトルネックが最後に立ちはだかります。VRAM(ビデオメモリ)へのアクセス速度です。

当時の標準的な解像度である256×192ピクセル。もしこれをドット(点)の集合として毎フレーム描き換えようとすれば、Z80プロセッサは他の処理を一切止めてVRAMへデータを転送し続けなければならず、それでも全く描画が間に合いません。画面は激しくチラつき(ティアリング)、ゲームやリアルタイム表現などは夢のまた夢でした。

全画面のピクセルを直接操作するという無謀な挑戦に対し、先人たちは極めてエレガントなパラダイムシフトで回答を示します。

「ピクセルを諦め、ポインタ(文字)を動かす」のです。

PCGがもたらした「文字の偽装」

8ビット機(特にMSXなどのアーキテクチャ)の多くは、画面をピクセルのキャンバスではなく、「32列×24行のテキストエディタ(文字列のマス目)」として捉えるモードを持っていました。

画面には「A」や「B」といった文字(キャラクター)が並びます。この文字の見た目(8×8ピクセルのドット絵)は、あらかじめ「キャラクタージェネレータ(CG)」と呼ばれるメモリ領域に定義されています。

ここでプログラマーたちは、システムに用意されたアルファベットのドット絵を、メモリ上で強引に上書きしてしまいました。これがPCG(Programmable Character Generator)です。 文字の「A」のグラフィックをレンガの壁に書き換え、「B」を宇宙船の左半分に、「C」を右半分に書き換える。

こうすることで、画面に宇宙船を描画するという行為は、数千個のピクセルを計算して転送する作業ではなくなります。 画面という名のテキストエディタの指定した座標に、ただ BC という「2バイトの文字コード(インデックス番号)」を書き込むだけの作業へと劇的に圧縮されるのです。

メモリ上のポインタ参照という究極の最適化

宇宙船を右に動かしたければ、元の場所の文字を「空白(スペース)」に戻し、右隣のマスに BC のコードを書き込むだけ。 背景をスクロールさせたければ、画面全体を構成する 32×24 = 768バイトの「文字コードの配列」の中身を一つずつズラすだけ。

これは現代のプログラミング用語で言えば、描画のたびに巨大なオブジェクトのディープコピーを作るのをやめ、「インスタンスへのポインタ(参照)」の配列だけを更新するのと同じ構造です。数万バイトのピクセル操作を、わずか数百バイトの配列操作(ポインタの書き換え)に落とし込んだ、究極のデータ圧縮でした。

ドットではなく、文字(タイル)の参照を動かして世界を記述する。 私たちが子供の頃に夢中になった8ビットゲームの滑らかなスクロールや巨大なボスの表現は、この「画面をテキストエディタだと騙す」という狂気的なハックの上に成り立っていたのです。

結び:0と1の限界点から見上げる宇宙

現代の私たちが、Astroで構築された高速なサイト空間の中で、Three.jsを用いて数百万の頂点やノイズの計算をブラウザ上でやすやすと実行できる環境。

8台のモニタが青白く光る静かな夜明け前のラボで、何の制約も感じることなく、純粋な数式とアルゴリズムの探求だけに没頭できること。それは、かつての計算機科学者や無名のプログラマーたちが、1バイトのメモリと1クロックの実行時間を削り出すために血の滲むような最適化を繰り返し、ハードウェアの重力を少しずつ切り拓いてきた歴史の上に成り立っています。

彼らが「計算しない」ために用いたLUTの魔法は、現代のGPUにおけるテクスチャ・サンプリングへと進化しました。文字(ポインタ)を並べるPCGの思想は、Three.jsのインスタンシング(InstancedMesh)として、何万ものオブジェクトをノーコストで描画する技術へと繋がっています。

0と1の限界点という極限の箱庭で生み出された「制約とハックの美学」は、決して過去の遺物ではありません。それは形を変え、抽象化されたAPIの奥底に静かに沈み、今も私たちの描画ループを支え続けているのです。