modified: 2009-10-25 .. 2013-06-18

DOS プログラミング


DOS パラノイア」「開発環境」「グラフィックス」「サウンド(Beep 音)


DOS パラノイア

DOS にこだわるというと、一般には FreeDOS だとか DOSBox だとか DOSEMU だとかそれだけでも結構、一部の風変わりな人たちの文化なのですが、その人たちにしても、過去に DOS が世間でも価値のあった時代をリアルタイムに経験している人たちで、それ故に、その頃の(自分達の青春時代の思い出の一杯詰った)ソフトウェア資産をどうにか維持・再現しようとしてレトロな情熱を燃やしてきたわけです。それら互換 DOS にしてもエミュレーターにしても開発活動がほとんど終息していて、既に完成されたものの実質的なメンテナンス期に入っています。

なのに今さら、ゼロから新しく、『DOS 用のソフトウェア(ゲーム)を開発でもしてみようか?』というのがここでの企画です。僕は、DOS 時代は単なる末端の1ユーザー(ゲームプレイヤー)に過ぎず、何ら IT 系人間としてのキャリアをスタートしていなかった元生物系・農学系学生出身の人間でした。その後、Web の展開期とともに徐々に自らもソフトウェアを作る側の技術を趣味的に身に付けてきましたが、何せそちらの業界の人間(玄人)ではありませんので過去の資料を掘り返すというわけにもいかず、DOS 時代の情報を現代においてゼロから新しく収集せねばなりません。むしろ情報が失われつつあるので、その当時にゼロからスタートする場合よりもずっと大変なことかもしれません……。

僕がなぜそこまでして DOS 環境にこだわるかというと、やっぱり、DOS 環境のハードウェアを直接叩いている「感じ」です。この感じというのが、プレイヤーとしての立場上の感覚的経験からも、大変捨て難いものがあるのです。処理速度の向上では埋められない「何か」があるのです。端的に言えば、リアルタイム OS としての特性です。これはいくらハードウェア的な処理速度を向上したところで、埋められない差です。だからこそ、UNIX や Windows のようなマルチタスク OS に対して、リアルタイム OS というものがカテゴリとして実際に存在しているわけです。生半可な工学系の人だと、DOS 時代と違って処理速度ははるかに向上したのだから、遅延は事実上ないに等しいと簡単に論破できたつもりになるかもしれません。しかし、これは無機工学的な問題というよりも、人間工学的な問題であり、人間の感覚の鋭さに対する認識が鈍感だと見逃すのです。人間は、ハードウェアの遅さに起因する遅延よりも、遅延の「不揃い性」に対してかなり敏感です。これが、ゲームのデバイスに対する操作感覚の「直結感(ダイレクト・フィーリング)」に決定的に作用します。リアルタイム OS の場合は、ハードウェア的な反応に対するソフトウェア的な遅延を均一にすることが容易にプログラミングにおいて調整(チューニング)可能です。これがマルチタスク OS の場合は、遅延が不均一になり、このダイレクトフィーリングの手応えが薄れます。DOS までの時代においては、PC ゲームも、DOOM のように伝説的アクションゲームが生まれる環境的素地はあったということができますが、PC が Windows の時代になって以降は、PC ゲームが斜陽化していって最終的にはアメリカの PC ゲーム業界が X-Box にごっそりと移住することになったのも、ある意味必然的な流れだったと言えます(もちろん一般的には「コンシューマゲーム機の性能が向上したから PC ゲームが衰退する流れとなった」と説明されているわけですが)。

ただし後述するように、僕はここでは実際には、Linux の DOSEMU 上でプログラムを開発・実行しています。当然、いくら DOS がリアルタイム OS だからといって、Linux 上で動かしたのではいくらエミュレーションが完璧でもリアルタイム性は有名無実化します。上述したのはあくまでも DOS に拘る上での大義名分(旗印)であって、現実問題としては、DOS なんて今時使う人はいません。おそらく僕がゲームを作ったところで、多くの人には DOSBox なりで動かしてもらうことを想定しています。しかし、DOS にしておけば、それは現実に、FreeDOS からブート FD または FD エミュレートした USB メモリー、El Torito 方式のブート CD などという形で、そのまま DOS として実行することも可能なのです。これが例えば(Intel Mac 以前の)Macintosh や PC-98 等の他のハードウェアの場合はそうはいきませんが、DOS は現行の PC-AT 互換機 でも今だに直接稼動させることができる点がポイントです。その上、FreeDOS のおかげで、ロイヤリティーの心配なく OS 丸ごと実行環境を配布することが可能であるというのもポイントです。アマチュアでゲームソフトを作るのに、実は「今だからこそ」DOS にメリットがあると見るのです。

(まあ、どーしても、現行のリアルタイム OS に拘るのであれば、最後の避難先として RealTime Linux という選択肢も残っていますが……)

開発環境(C コンパイラー等)は DJGPP を使用

今のところ、Linux で DOSEMU を走らせて開発しています。DOSEMU でも DOSBox でもどちらでも構わないのですが、僕の非力な旧式マシン(Pentium III 1.4-S)では、DOSEMU と DOSBox のパフォーマンス上の差は決定的なので、DOSEMU にします(DOSBox は CPU を含めたハードウェアもエミュレーションするので、互換性・移植性は高いが、パフォーマンス面では不利になる)。

従って、OS 環境は、MS-DOS とは互換性があるものの、直接的には無関係です。DOSEMU では FreeDOS を利用していることになります。ちなみに DOSBox ではそもそも OS を別途必要としないようです。

もちろん、本物の DOS ブート環境下で開発を行うというのも一つの手ですが、プログラムが暴走したらアウトなので、エミュレーター環境の方がずっと快適でしょう。エミュレーター環境であれば、その傍らでインターネットで情報を参照することができます。プログラムソースの編集自体は、DOS の IDE を使う予定ですが、gedit のような Linux のテキストエディターを使うこともできます。

プログラミング環境は、DJGPP を使います。IDE 無しでテキストエディタ(gedit)のみで作成するか、IDE を使う場合は RHIDE(DJGPP の FTP アーカイブにも含まれている)を使います。DJGPP 環境のセットアップについては、おそらく他の情報源の方が詳しく正確だと思うので今は端折りますが、コンパイラ以外にライブラリやツール群を含む複数の zip ファイルをともかく C:\DJGPP ディレクトリー以下に展開して、環境変数と PATH を autoexec.bat を利用して設定するという感じの作業となります(参考サイト)。

DOS プログラミングの概略

後の章の「グラフィックス」以降にだらだらと勉強しながら同時進行形で書き連ねたメモがありますが、一通り終った今になって振り返ると、全然まとまっていない判り辛い長文となっていると思われます。ちゃんとまとめ直す必要があると思いますが、今はそこまでのモチベーションがありませんので、とりあえず概略というか全体像を述べます。

結局、ゲームプログラムにおける DOS プログラミングの特徴という点では、「入出力デバイスの制御方法」を学ぶという点に尽きます。Windows や Linux では DirectX だの SDL だのといった API にすべて面倒を見てもらうことになりますので、入出力デバイスについては具体的に意識する必要がありません。出力デバイスというと、まずはグラフィックスであり、次にサウンドであり、また入力デバイスというと、まずはキーボードであり、場合によってはマウスやジョイスティックということになります。グラフィックスについては、VGA 規格の範囲内での制御であれば比較的難しくありませんが、SVGA(VBE 規格)で制御しようとするともう少し詳しい勉強が必要になってきます。サウンドは互換性の上からも Beep 音はともかく押えておく必要があるでしょう。DOSBox を前提とする場合は SB16 互換も基準とできるので、Beep 音以上のちゃんとしたサウンドを目指す場合には、SB16 か MIDI を視野に入れる必要がありますが、そのときは Beep 音よりもより詳しい勉強が必要になります。

基本的に、これらの入出力デバイスの制御は、API を利用する場合と違って、ハードウェアのデータシート等の情報を収集する必要があります。API の場合はあくまでも直接操作するのはソフトウェアなので、ソフトウェアの提示してくれている情報(関数の利用方法)を調べればいいのです。しかし、DOS の場合は、ハードウェアのデータシートを調べて、そこから導き出される、割り込み等によるデータの受け渡し方法によって自分でプログラムしなければなりません(要するに自分でドライバーをプログラムするのです)。そういったデータシートから導き出される実際のプログラム例として、先人のプログラムソースが大変参考になりますが、基本的に DOS プログラミングにおける情報はインターネット上では、日本語の情報がほとんど存在せず、10 〜 20 年くらい昔の英語の文献を頼るしかありません。そして時代的にその頃のプログラムはアセンブリ言語か Pascal 言語を使ったものが多く、必ずしも C 言語ではないのが難点です。

そしてもう一つの点が、これが大きな DOS プログラミングの急所ですが、Intel x86 プロセッサ特有の「リアルモード」「プロテクトモード」に関連する制御です。このリアルモードとプロテクトモードというのは厳密な表現からすればいずれもメモリー空間のアドレス制御方法の区別による呼び分けで「リアルアドレス(実アドレス)・モード」「ヴァーチャルアドレス(仮想アドレス)・モード」という違いです。後者の仮想アドレスモードでは、実アドレスに直接アクセスできず、また他のプログラムプロセスからメモリー空間が隔離されているので、プロテクト(保護)された状態となります(つまり、本当は「仮想」という名称の方が実態を表わしているが、Intel はネガティブな印象を嫌って、メリットとしての「保護」を前面に押し出したかったのでしょう)。これは、Windows や Linux のようにマルチタスク OS では都合が良いのですが、DOS のようにハードウェア(の実アドレス)に直接アクセスしたい場合には、却って障壁となります。そこで、VRAM などの実アドレスに直接アクセスする場合には、都度、リアルモードに切り替えて、動作するようにプログラムする必要が生じます。そしてリアルモードに切り替えた場合には、プロテクトモード時の 32bit アドレッシングの利益も失うので、16bit アドレッシングの枠組みでメモリーにアクセスしなければなりません。このような使い分けの所で、プログラムにバグが発生しやすく、特に最初のうちはハードウェアの使い方に問題があるのかと思って悩んだりしたのですが、実は、適切なリアルモードの手順によって制御していなかったことが原因だったと判明することが少なくありませんでした。

近況

今のところ、昔 QBASIC で作った DOS の習作ゲーム『勇者ハニワ』を C 言語で動くように移植することを念頭に置いています。そのために QBASIC インタプリターが全部面倒を見てくれていた、グラフィックスやサウンド、キーボードの低レベルなドライバー機能を全て自分で実装する必要があり、それだけで1ヶ月近くかかっています。とはいえ、ハードウェアと DOS プログラミング全般のことをほとんど一から勉強すると同時に、C 言語自体も入門者レベルからの学習を並行しているので、余計に手間がかかっています。

本当は、DOS のゲームプログラミングであれば、Allegro というオープンソースのライブラリーがあり、自分で一から実装する必要性はありません。僕の場合は、DOS の低レベルなプログラミングを敢えて行うことによって、ハードウェアとプログラムの密接な関係性の中で両者についての理解を深めるという目的があったので、わざとこのようなことを行っています。車輪の再発明こそ、学習には最適の方法だと思うのです(そうでなければ、例えば小学生は四則演算を勉強する必要がなくなるわけです)。


グラフィックス

ではまずは、グラフィックス制御から始めていきます。基本的に、Devid Brackeen さんのテキスト「256-Color VGA Programming in C」の確認と検証を通じて自ら理解していきたいと思います。僕は C 言語は、まともに使ったことがないので、この DOS ゲーム開発の企画は、C 言語の練習も兼ねています。そのため、テキストの英文の解説はざっと読んで理解できたのですが、C 言語のソースについては、C 言語の教科書(K&R 本)を参照しないでそれだけで理解できるというわけにはいかないので、ともかくソースの解析は後回しにして、テキストのサンプルソースがちゃんとコンパイルできて自分の環境で動作することを確認しました。

ほとんどのサンプルが問題なく動作することが確認できました。ただし、最後から 2 番目のサンプルプログラム unchain.c が SIGSEGV エラーになります。この unchain.exe はコマンドラインオプションで描画するボールの数を指定できるのですが、指定しない場合にエラーになり、指定すればちゃんと実行できます。このエラーは、303 行目の if (argc>0) の部分を if (argc>1) にすると解消できます。

またコンパイル時に gcc が出す警告は #include <string.h> を明記すれば出なくなります。それ以外は特に問題は発生しません。

RHIDE について

RHIDE はエディターとしての機能とコンパイラ・デバッガとしての機能の部分が並立しているようです。そもそも僕は今までコンパイラ系言語を使ったことがないので何も知らなかっただけで、IDE というもの自体が元々こういうなのかもしれませんが、「プロジェクト」という概念に最初は戸惑いました。普通にファイルオープンでソースファイルを開いてコンパイル・実行できたのはいいのですが、自動的にそのファイルがプロジェクトのファイルリストに登録されて、次に別のソースファイルを単体でコンパイルしたいと思っても、先程のファイルと勝手にリンクされて全体で一つのプログラムと解釈されてしまうのです。その結果、main() 関数が重複していると怒られます。それでプロジェクトのメニューから Add item や Delete item でリスト自体を入れ替える必要があることに気付きました。ソースファイルのリストは右下の欄に表示されています。また、場合によっては、main targetname を設定して生成される exe ファイルの名前を自分で決めておきたい場合もあるでしょう。ともかく、ファイルオープンでエディタ画面に表示されるソースファイルがコンパイルの対象ではあるのですが、コンパイルされた結果リンクされて exe ファイルとして出力されるのはプロジェクトで設定されているものに従っているということ(コンパイルはオブジェクトファイルしか作成せず、RUN した時にリンクされるようです)がわかってしまえば、今後は RHIDE を使った開発はスムーズにやれそうです。

以上で開発環境が正しく構築されていること、Brackeen さんのテキストの情報が有効なことが確認できたので、安心してテキストの内容の解読に入ることができます。

VGA の原理

まずはテキストの「VGA Basics」からです。ここで解説されている「VGA の原理」とは、要するに、VRAM にデータを書き込むことが、画像を描画するという結果につながるという話です。これはある程度コンピュータの原理について勉強したことがある人にとっては、非常にシンプルな原理ですが、コンピュータの素人からすれば、この事実に気付くこと(気付く機会を得ること自体)が驚異的なことです。また逆に高級言語に偏って学習した人の場合は、OpenGL だとか DirectX の様々な描画用の関数は知っていても、実際にはこんなにシンプルな単なる「メモリの(読み)書き」がグラフィックスの全てだというのに実感が湧かないものを感じるかもしれません。僕は以前から色々な情報で理屈としては理解していたのですが、やはり自分で実際にやってみないと、感覚的にしっくりこないため、今回はこのような試みを行うことにしたのです。

VRAM をどのように割り付けるかによって、画面モードの種類が複数存在します。DOS ゲーム時代は 320x200 のモード 0x13 が良く使われたようで、DOS 関係の文献に頻見します。

pixel.c

このサンプルでは、おおまかに言って、画面モードを 0x13 に切り替え、ランダムな点を描画し、終ったら画面モードをテキスト用のモード 0x03 に戻すということをやっています。その上、描画方法を 2 種類用意して速度の差をベンチマークするような内容となっているので、その分コードが肥大化していますが、画面モードの切り替えと描画自体のコードは非常にシンプルなのでやる気が湧きます。まずは、コードの無駄な部分を削って、核心的な部分だけのものに書き換えてみたいと思います。

#include <stdio.h>
#include <stdlib.h>
#include <dos.h>
#include <sys/nearptr.h>

ライブラリーですが、C 言語入門レベルの僕にはまだハッキリとどのライブラリーがどの関数と対応しているのかはわかりません。おそらく特徴的なのは、dos.h と sys/nearptr.h だと思います。

#define VIDEO_INT           0x10      /* the BIOS video interrupt. */
#define WRITE_DOT           0x0C      /* BIOS func to plot a pixel. */
#define SET_MODE            0x00      /* BIOS func to set the video mode. */
#define VGA_256_COLOR_MODE  0x13      /* use to set 256-color mode. */
#define TEXT_MODE           0x03      /* use to set 80x25 text mode. */

#define SCREEN_WIDTH        320       /* width in pixels of mode 0x13 */
#define SCREEN_HEIGHT       200       /* height in pixels of mode 0x13 */
#define NUM_COLORS          256       /* number of colors in mode 0x13 */

定数のマクロ定義です。

typedef unsigned char  byte;
typedef unsigned short word;

byte と word という別名の 2 つの型の定義しています。要するに定数の定義と同様ですが、コードの可読性の向上という目的のためですね。

byte *VGA = (byte *)0xA0000;          /* this points to video memory. */
word *my_clock = (word *)0x046C;      /* this points to the 18.2hz system clock. */

VRAM アドレスの先頭番地とシステムクロックの番地をポインターで指定していますが、システムクロックは描画時間のベンチマークのために利用しているようなので、削ることができそうです。

以上で、定数などのマクロ定義部分は終って、残りは関数の定義とメインルーチンになります。

void set_mode(byte mode) {
  union REGS regs;

  regs.h.ah = SET_MODE;
  regs.h.al = mode;
  int86(VIDEO_INT, &regs, &regs);
}

画面モードの切り替えに関する関数。CPU の AH レジスターに BIOS の関数番号を、と AL レジスターに画面モード番号をセットして、BIOS のビデオ割り込み機能をコールします。ということは int86() という関数は dos.h の関数っぽいですね。むしろ union REGS regs や regs.h.ah, regs.h.al, &regs の使い方がよくわからないが、調べてみるとどうやら REGS という型が dos.h で定義されている共用体のようです(参考:共用体その2)。つまり、reg.h.ah, reg.h.al ではその共用体で定義されている XREGS(x), HREGS(h) という 2 種類の構造体のうち、HREGS としてアクセスすることを意味し、その構造体のうちの ah, al という特定箇所のデータのみを書き換えています。int86() 関数で &regs, &regs と 2 つ並べているのがちょっと謎だが、調べてみると本来は後ろのポインターアドレスは戻り値を指定する引数のようです(参考:DOSでプログラムを作ってみよう!)。同じアドレスにしてしまっても、戻り値が書き込まれる時点ではもう BIOS をコールするわけではないので、問題ないということで同じアドレスにしているのでしょう。

void plot_pixel_slow(int x,int y,byte color) {
  union REGS regs;

  regs.h.ah = WRITE_DOT;
  regs.h.al = color;
  regs.x.cx = x;
  regs.x.dx = y;
  int86(VIDEO_INT, &egs, &regs);
}

void plot_pixel_fast(int x,int y,byte color) {
  VGA[y*SCREEN_WIDTH+x]=color;
}

ここが点の描画部分になります。plot_pixel_slow() の方は敢えて BIOS 関数を使って一つ一つの点を描画しています。plot_pixel_fast() の方が本来のやり方で VRAM の内容を直接書き換えています。

ところが、ここで問題が発生。環境構築テストの時はとりあえずこのサンプルプログラムが動作したので見落していたのですが、実際には、plot_pixel_fast() の方のベンチーマークの結果が 0 秒となっており、ちゃんと走っていないようなのです。もしかしたら、DOSEMU の問題かもしれないし、それ以前の Linux による制限かもしれません。何しろ、VRAM に直接書き込むというかなりハードウェア的に低レベルのことを行っているわけです。そこで、Windows の DOSBox 上でサンプルプログラムを走らせてみましたが、ちゃんと plot_pixel_fast() も動いています(plot_pixel_slow() の 3 倍ぐらいの速さでしょうか)。そこで再び Linux に戻って今度は DOSBox 上で走らせてみたところ、やはりちゃんと動いています。ここで恐ろしい事実が判明します。もしかしたらあまりにも速過ぎる可能性も残されていなくはないと思い、main() の中の描画回数を 50000 回から、10000000 回に変更してみました。するとようやく、視認可能なレベルで plot_pixel_fast() での描画が実行されたのです。ベンチマーク的には、 plot_pixel_slow() の 125 倍の速さです。これはある意味当然かも知れません。DOSBox ではハードウェアもソフトウェア的にエミュレーションされた架空のデバイスですが、DOSEMU の場合は、ハードウェアはエミュレーションされずに実際のハードウェアが利用されるので、メモリの書き換え速度が非常に強化された VRAM の操作は、BIOS を通じていちいち CPU 経由で操作する場合の比ではありません。要するに、DOSEMU の性能というよりは、本物の DOS を使った場合に引き出されるはずの、VGA の本来的な性能なわけです。

それにしても、Brackeen さんがおそらくテキストとサンプルプログラムを作成した頃のまだ DOS が生きていた時代のハードウェアの性能に比して、はるかに機能が向上したことがわかるので「恐ろしい」と表現したのです。当時ならば描画回数 50000 回で十分だったわけです。僕のはこんな古いマシンとは言っても、バスは AGP 4X ですし、VGA は GeForce FX 5700 です。DOS 時代のハードウェアとは遥かな開きがあります。

これらコードは以上のようにベンチマークとして 2 通りで記述されているわけで、実際には plot_pixel_fast() だけ使えばいいことになります。

void main() {
  int x,y,color;
  float t1,t2;
  word i,start;

  if (__djgpp_nearptr_enable() == 0) {
    printf("Could get access to first 640K of memory.\n");
    exit(-1);
  }

  VGA+=__djgpp_conventional_base;
  my_clock = (void *)my_clock + __djgpp_conventional_base;

  srand(*my_clock);                   /* seed the number generator. */
  set_mode(VGA_256_COLOR_MODE);       /* set the video mode. */

  start=*my_clock;                    /* record the starting time. */
  for(i=0;i<50000L;i++) {             /* randomly plot 50000 pixels. */
    x=rand()%SCREEN_WIDTH;
    y=rand()%SCREEN_HEIGHT;
    color=rand()%NUM_COLORS;
    plot_pixel_slow(x,y,color);
  }

  t1=(*my_clock-start)/18.2;          /* calculate how long it took. */

  set_mode(VGA_256_COLOR_MODE);       /* set the video mode again in order
                                         to clear the screen. */

  start=*my_clock;                    /* record the starting time. */
  for(i=0;i<50000L;i++) {             /* randomly plot 50000 pixels. */
    x=rand()%SCREEN_WIDTH;
    y=rand()%SCREEN_HEIGHT;
    color=rand()%NUM_COLORS;
    plot_pixel_fast(x,y,color);
  }

  t2=(*my_clock-start)/18.2;          /* calculate how long it took. */
  set_mode(TEXT_MODE);                /* set the video mode back to
                                         text mode. */

  /* output the results... */
  printf("Slow pixel plotting took %f seconds.\n",t1);
  printf("Fast pixel plotting took %f seconds.\n",t2);
  if (t2 != 0) printf("Fast pixel plotting was %f times faster.\n",t1/t2);

  __djgpp_nearptr_disable();

  return;
}

そして最後に main() です。基本的にベンチマークのための部分を刈り込んでいけば良さそうですが、ちょっと待ってください。__djgpp_nearptr_enable() だとか __djgpp_nearptr_disable() というのは何でしょうか? 調べてみたところ、DJGPP のサイトに情報がありました(参考:DJGPP VGA access)が、VRAM にデータを書き込む方法として 3 通りの方法を紹介しており、1 番目はフレームバッファの内容全体を更新するやり方らしく、3 つの中で一番遅いと説明されています(当然かもしれませんね)。次の 2 つがポインタを使ったメモリー内容の変更になりますが、おそらくその方が速い理由は、CPU で一旦変数のメモリーの内容を読み出して、それを目的の番地のメモリーに書き込むという手順の場合は、メモリーと CPU との間でデータの入出力が発生するからだと思います。一方、ポインターの場合は、CPU にメモリーの内容を読み込まず、メモリーユニットに対して番地間のデータの書き換えだけ指示し、書き換えはメモリーユニット内部で行われるので、CPU との間でのデータの入出力は発生しないのだと思います。

このポインターを使った VRAM の書き換えに 2 通りあって、far ポインターと near ポインターのやり方があります。つまり、far ポインターの場合はプロテクトモード下で普通に絶対アドレスを指定する方法ですが、near ポインターの場合は、同一セグメント内の相対アドレスによる移動となります。当然、後者の方が最大でも 65535 byte(0x0000〜0xFFFF)以内の移動となるので、メモリーの処理速度に違いが生じることになります。ただしこの場合は、プロテクト(仮想アドレス)モードでせっかくセグメント境界を意識する必要がなくなってフラットなメモリーモデルを使える状況なのに、敢えて相対アドレスを用いるので、運悪くセグメントの開始番地が、DOS のシステム領域(物理メモリの先頭から 65535 byte の領域)に近過ぎると、相対アドレス指定によってセグメントの境界を前方に乗り越えてしまった場合に、システム領域に侵入してしまう可能性があるわけです。__djgpp_nearptr_enable() によって near ポインタを有効にしますが、システム領域を侵してしまった場合に戻り値が 0 となるようです。そして問題なく有効にできた場合は、VRAM(やシステムクロック)の開始番地を __djgpp_conventional_base の分だけ後方に補正しています。VRAM(やシステムクロック)のアドレスが実際に変化することは考えられないので、どういうことなのかと悩みましたが、要するに、__djgpp_nearptr_enable() をすると、ポインターのアドレスの扱いが __djgpp_nearptr_disable() で再び無効化するまでの間は、常に near ポインター、すなわち相対アドレスとして扱われることに気付けば理解できます。相対アドレスとしては、本来はプロテクト(保護)されて除外されていたはずのコンベンショナルメモリーの分のアドレス空間も勘定に入れなければならないからです。(参考:Start VGA programming

ここまで理解できれば、あとは単に、画面サイズなどに応じて計算した VRAM の適切な番地に対して、適切な色を表示するための値を書き込めばいいだけ、という話になります。ちなみに、コードを拡張して、meminput と far ポインターによる描画のベンチマークも比較できるようにしたサンプルコード(pixel3.c)も作成しましたが、説明は割愛します。

ベンチマーク関係の刈り込み

まず、ベンチマークに関する余分な部分を刈り込んで、単に高速モードでランダムにドットを描画するプログラムに改変します(描画回数も変えます)。以下がその pixel2.c となります。

/*
pixel2.c: Masanori HATA <http://www.mihr.net/>
[original] pixel.c: David Brackeen <http://www.brackeen.com/home/vga/>
*/

#include <stdio.h>
#include <stdlib.h>
#include <dos.h>
#include <sys/nearptr.h>

#define VIDEO_INT           0x10      /* the BIOS video interrupt. */
#define WRITE_DOT           0x0C      /* BIOS func to plot a pixel. */
#define SET_MODE            0x00      /* BIOS func to set the video mode. */
#define VGA_256_COLOR_MODE  0x13      /* use to set 256-color mode. */
#define TEXT_MODE           0x03      /* use to set 80x25 text mode. */

#define SCREEN_WIDTH        320       /* width  in pixels of mode 0x13 */
#define SCREEN_HEIGHT       200       /* height in pixels of mode 0x13 */
#define NUM_COLORS          256       /* number of colors in mode 0x13 */

#define LOOP                10000000  /* number of loops for plotting pixels */

typedef unsigned char  byte;
typedef unsigned short word;

byte *VGA = (byte *)0xA0000;          /* this points to video memory. */

void set_mode(byte mode) {
  union REGS regs;

  regs.h.ah = SET_MODE;
  regs.h.al = mode;
  int86(VIDEO_INT, &regs, &regs);
}

void plot_pixel(int x, int y, byte color) {
  VGA[y * SCREEN_WIDTH + x] = color;
}

void main() {
  int x, y, color;
  unsigned long i;

  if (__djgpp_nearptr_enable() == 0) {
    printf("Could get access to first 640K of memory.\n");
    exit(-1);
  }

  VGA += __djgpp_conventional_base;

  set_mode(VGA_256_COLOR_MODE);       /* set the video mode. */

  for(i = 0; i < LOOP; i++) {
    x = rand() % SCREEN_WIDTH;
    y = rand() % SCREEN_HEIGHT;
    color = rand() % NUM_COLORS;
    plot_pixel(x, y, color);
  }

  set_mode(TEXT_MODE);                /* set the video mode back to text mode. */

  __djgpp_nearptr_disable();

  return;
}

関数もたった 2 つだけで、main() では画面モードを切り替えて、描画し、画面モードを元に戻すという流れの非常に簡単なプログラムです。これだけで一応の画面制御ができていることがわかります。

メモリーアドレス計算ルーチンの高速化

テキストでは「y * SCREEN_WIDTH + x」の部分、すなわち SCREEN_WIDTH = 320 ですから、「y * 320 + x」ということになりますが、この部分を「(y<<8) + (y<<6) + x」というアルゴリズムにすることによって高速化できると説明しています。これは特に画像処理の話とは直接関係のない、いわゆる計算機学的なアルゴリズムの話ですね。アセンブリ言語などの基礎的な勉強をしたことがある人ならばわかるかもしれませんが、「<<」というのはビットシフト演算であり、二進数の演算においては、ビットの左シフトは値が 2 倍になることを意味します。つまり 320 倍するということは、256 倍 + 64 倍と同じことであり、256 倍は 8 回のビットシフト演算に、64 倍は 6 回の左ビットシフト演算に置換することができます。十進数の掛け算の演算よりも二進数的な単純な演算の方がコンピュータでは速く処理できるのです。あくまでも、画像処理のプログラミングの話としては脇道の話なので、速度的な最適化のノウハウなんだなと考えればいいと思います。

素朴な形状と線

次にテキストの「Primitive Shapes & Lines」です。VRAM の該当アドレスにデータを書き込むことで点を描画できることがわかったので、その原理を組み合せて、直線・四角形・円形などを描画するアルゴリズムについて説明しています。

lines.c

直線の描画というと 2 つの点 (x1, y1)、(x2, y2) の座標を決めて、その間に線を引くというのが考えられる方法です。では、その 2 点間のどの点を線の一部として表示する点と判断すればいいのか、という話になります。まず普通のアルゴリズムとして、関数のグラフを描画する場合のように考えるやり方が紹介されています。X 軸の方向と Y 軸の方向のどちらか一方を基準とします。どちらを基準とするかは、増加量の多い方を基準軸とします。例えば、y = 0.5x のように、傾きが 1 より小さい直線の場合は、x が 2 増加してやっと y が 1 増加しますから、この場合は X 軸方向が基準となります。そして基準軸である x の値を x1 から 1 ずつ増加させていって、それぞれの y の値を計算します。小数点以下は四捨五入するなどします。こうやって x1 から x2 の間にある x 座標に対応する y 座標を一つ一つ決めていって、合計「x2 - x1 + 1」個の点をプロットすればいいわけです。

void line_slow(int x1, int y1, int x2, int y2, byte color) {
  int dx, dy, dxabs, dyabs, sdx, sdy, i, px, py;
  float slope;

  dx = x2 - x1;      /* the horizontal distance of the line */
  dy = y2 - y1;      /* the vertical   distance of the line */
  dxabs = abs(dx);
  dyabs = abs(dy);
  sdx = sgn(dx);
  sdy = sgn(dy);
  if (dxabs >= dyabs) { /* the line is more horizontal than vertical */
    slope = (float)dy / (float)dx;
    for (i=0; i != dx; i += sdx) {
      px = i + x1;
      py = slope * i + y1;
      plot_pixel(px, py, color);
    }
  } else { /* the line is more vertical than horizontal */
    slope = (float)dx / (float)dy;
    for (i = 0; i != dy; i += sdy) {
      px = slope * i + x1;
      py = i + y1;
      plot_pixel(px, py, color);
    }
  }
}

このサブルーチンも一見長いようですが、X 軸の方向を基準とするか Y 軸の方向を基準とするかで if ブロックによって 2 通り書いているので長く見えるだけで、アルゴリズムの核となる部分は、if ブロックの内側の傾きを計算して for ループで点の座標を一つ一つ計算してプロットしている部分であることがわかります。

ただし、よく見ると、for ループの終了条件が「i != dx」「i != dy」となっていますが、これは「i <= dx」「i <= dy」とするべきでしょう。

さて、ここではさらに Bresenham のアルゴリズムというより高速化された描画方法が紹介されています。

void line_fast(int x1, int y1, int x2, int y2, byte color) {
  int dx, dy, dxabs, dyabs, sdx, sdy, x, y, px, py, i;

  dx = x2 - x1;      /* the horizontal distance of the line */
  dy = y2 - y1;      /* the vertical   distance of the line */
  dxabs = abs(dx);
  dyabs = abs(dy);
  sdx = sgn(dx);
  sdy = sgn(dy);
  x = dyabs >> 1;
  y = dxabs >> 1;
  px = x1;
  py = y1;

  VGA[(py << 8) + (py << 6) + px] = color;

  if (dxabs >= dyabs) { /* the line is more horizontal than vertical */
    for (i = 0; i < dxabs; i++) {
      y += dyabs;
      if (y >= dxabs) {
        y -= dxabs;
        py += sdy;
      }
      px += sdx;
      plot_pixel(px, py, color);
    }
  } else { /* the line is more vertical than horizontal */
    for (i =0; i < dyabs; i++) {
      x += dxabs;
      if (x >= dyabs) {
        x -= dyabs;
        px += sdx;
      }
      py += sdy;
      plot_pixel(px, py, color);
    }
  }
}

このアルゴリズムも掛け算・割り算を足し算・引き算やシフト演算で置換することによって高速化を図っているようです。

基本的にこのサンプルプログラムで修正すべきポイントは、前述の line_slow() 関数における for ループの終了条件だけですが、僕の環境では、DOSEMU で実行しているせいか、普通に 5000 回のループで実行すると、line_slow() も line_fast() も全く同じ速さとなります。そこでループ回数を増やしてみたのですが、増やし過ぎると、プログラムがフリーズしてしまい、CTRL+C で強制終了しなければならなくなります。それでフリーズしないギリギリの値に増やしてみたところ、line_fast() の方が 1.7 倍速いという結果が出ました。どうやら、DOSEMU で画像関係の処理がバッファされているみたいで、それが原因ではないかと考えています。5000 回のループでは双方とも一瞬でバッファがフラッシュされて一度で処理が終ってしまい、多過ぎるループ回数 では逆にバッファが溢れてしまったのだと思います。

多角形

多角形は、上記の直線を描画する line() 関数を使う別のルーチンを作ることで簡単に実装できると説明されています。与えられた複数の点の座標を一筆書きのような感じで次々に線で結んで行きます。

void polygon(int num_vertices, int *vertices, byte color) {
  int i;

  for (i = 0; i < num_vertices - 1; i++) {
    line(vertices[(i << 1) + 0],
         vertices[(i << 1) + 1],
         vertices[(i << 1) + 2],
         vertices[(i << 1) + 3],
         color);
  }
  line(vertices[(num_vertices << 1) - 2],
       vertices[(num_vertices << 1) - 1],
       vertices[0],
       vertices[1],
       color);
}

for ループでは与えられた点を順番にずらしながら次の点と結んでいます。最後の line() 関数を利用している部分は最後の点と最初の点を結んでいます。

rect.c

次に四角形について説明されていますが、四角形については、対角線上の 2 点(左上と右下)を指定して描画する関数となるのが普通です。先述の多角形描画ルーチンと同様にして、line() 関数を組み合せるようにしても四角形は描けます。しかし、Brackeen さんの説明によると、アルゴリズム的に、line() 関数を利用せずに専用のルーチンを用意した方がいいようです。というのも、四角形の場合、辺を構成する直線は、水平線と垂直線だけなので、わざわざ line() 関数でやったような、斜め線を描くための計算は必要がないからです。

void rect_fast(int left, int top, int right, int bottom, byte color) {
  word top_offset, bottom_offset, i, temp;

  if (top > bottom) {
    temp = top;
    top = bottom;
    bottom = temp;
  }
  if (left > right) {
    temp = left;
    left = right;
    right = temp;
  }

  top_offset    = (top    << 8) + (top    << 6);
  bottom_offset = (bottom << 8) + (bottom << 6);

  for (i = left; i <= right; i++) {
    VGA[top_offset    + i] = color;
    VGA[bottom_offset + i] = color;
  }
  for (i = top_offset; i <= bottom_offset; i += SCREEN_WIDTH) {
    VGA[left  + i] = color;
    VGA[right + i] = color;
  }
}

if ブロックについては、2 点として左上と右下ではなく反対の順番で右下と左上とか、左下と右上とか、右上と左下とかいう順番で指定されてしまった場合に入れ替えて修正するためのユーザーインターフェース的な部分なので、特に問題はありません。

まず、top_offset と bottom_offset で四角形の上辺の行の開始メモリーアドレスと、下辺の行の開始メモリーアドレスを算出しています。そして、最初の for ループで上辺と下辺の行について左辺から右辺までの間の点をすべて描画することで描いています。次の for ループでは、一行ずつ下にずらしながら、各行ごとに左辺と右辺の 2 点を描画していきます。ちなみにこの 2 番目の for ループで描く左辺と右辺は、角の部分で上辺・下辺と重複して描いています。ですから重複を除けば次のように修正しても ok でしょう。

  for (i = top_offset + SCREEN_WIDTH; i < bottom_offset; i += SCREEN_WIDTH) {
    VGA[left  + i] = color;
    VGA[right + i] = color;
  }

そして次にこの四角形の描画ルーチンと同じような考え方で、四角形の塗り潰しルーチンもサンプルコードに含まれています。こちらの場合は、四角形よりもさらに簡単です。というのも、同じ長さの水平線を描いて積み重ねればいいからです。

void rect_fill(int left, int top, int right, int bottom, byte color) {
  word top_offset, bottom_offset, i, temp, width;

  if (top > bottom) {
    temp = top;
    top = bottom;
    bottom = temp;
  }
  if (left > right) {
    temp = left;
    left = right;
    right = temp;
  }

  top_offset    = (top    << 8) + (top    << 6) + left;
  bottom_offset = (bottom << 8) + (bottom << 6) + left;
  width = right - left + 1;

  for (i = top_offset; i <= bottom_offset; i += SCREEN_WIDTH) {
    memset(&VGA[i], color, width);
  }
}

上辺から下辺までの間、一行ずつ処理をループしています。各行において、memset() 関数を使って、左辺から右辺までの一定の範囲に同じ値をセットしているのがわかります。

この rect.c においても、ループ回数を調整しないと、例えば、塗り潰しルーチンの実行結果などは、速過ぎて見えないという現象が発生しますが、他には特に問題はないと思います。

テキストではさらに円の描画の説明がされていますが、簡単に Brackeen さんの解説をまとめると、角度を使った三角関数の計算はかなり重くなるので、あらかじめ角度ごとに計算した sin などの表を用意しておくというテクニックが有用だということです。また、浮動小数点数を使った計算も重くなるので、整数を組み合せて固定小数点数を表現するテクニックも有用だということです。これ以上はゲームの制作では円を直接描画することはあまりなさそうなので、割愛します。おそらく円や曲線を描く場合は、ビットマップの表示によって行えば用が足りると思います。

ビットマップと色パレットの操作

次はテキストの「Bitmaps & Palette Manipulation」です。線や円の描画の解説でちょっと頭が疲れてしまっていて、ビットマップはもっと高度なんじゃないかとビビってしまうかもしれませんが、実はビットマップの描画はむしろ簡単です。単純にピクセルの集合体を適切な場所に描画すればいいだけだからです。むしろ四角形の描写に感じは似ています。以下のサンプルコードは、64x32 のサイズのビットマップが仮定されていますが、実際の Brackeen さんのサンプルコード bitmap.c では、ビットマップファイルのサイズを判断するルーチンが使われています。

for (y = 0; y < 64; y++)
  for (x = 0; x < 32; x++)
    VGA[y * 320 + x] = bitmap[y * 32 + x];

ここでも行ごとに分けて左側から一つずつ点を描いていくやり方が使われているのがわかります。

また、透明部分を持つビットマップの描写の場合は、次のようにビットマップの色が透明にする色(サンプルコードの場合は 0)の場合に、メモリーの書き換えを行わないようにすればいいのです。

for (y = 0; y < 64; y++)
  for (x = 0; x < 32; x++) {
    data = bitmap[y * 32 + x];
    if (data != 0) VGA[y * 320 + x] = data;
  }

ただ、ビットマップの場合は、ビットマップファイルのデータを配列に読み込む部分が必要で、そのためにはビットマップファイルのフォーマットを理解していなければなりませんが、Brackeen さんも説明されているように、Windows の RGB-encoded BMP フォーマットは非常に単純なので、あまり問題にはならないと思います。とはいえ、コード的には、描画部分よりもむしろこちらの方に行数を要します。

bitmap.c

それではサンプルコードの方を見ていきます。

typedef struct tagBITMAP {            /* the structure for a bitmap. */
  word width;
  word height;
  byte *data;
} BITMAP;

ビットマップデータを読み込むための構造体を定義しています。横と縦のサイズ、3 番目がビットマップデータの内容の配列へのポインタです。

void fskip(FILE *fp, int num_bytes) {
   int i;
   for (i = 0; i < num_bytes; i++)
      fgetc(fp);
}

これはビットマップファイルのヘッダを読む際に読み飛ばしてスキップするための関数です。バイト単位でスキップする数を指定します。

void load_bmp(char *file, BITMAP *b) {
  FILE *fp;
  long index;
  word num_colors;
  int x;

  /* open the file */
  if ((fp = fopen(file, "rb")) == NULL) {
    printf("Error opening file %s.\n", file);
    exit(1);
  }

  /* check to see if it is a valid bitmap file */
  if (fgetc(fp) != 'B' || fgetc(fp) != 'M') {
    fclose(fp);
    printf("%s is not a bitmap file.\n", file);
    exit(1);
  }

  /* read in the width and height of the image, and the
     number of colors used; ignore the rest */
  fskip(fp, 16);
  fread(&b->width,   sizeof(word), 1, fp);
  fskip(fp, 2);
  fread(&b->height,  sizeof(word), 1, fp);
  fskip(fp, 22);
  fread(&num_colors, sizeof(word), 1, fp);
  fskip(fp, 6);

  /* assume we are working with an 8-bit file */
  if (num_colors == 0) num_colors = 256;

  /* try to allocate memory */
  if ((b->data = (byte *) malloc((word) (b->width * b->height))) == NULL) {
    fclose(fp);
    printf("Error allocating memory for file %s.\n", file);
    exit(1);
  }

  /* Ignore the palette information for now.
     See palette.c for code to read the palette info. */
  fskip(fp, num_colors * 4);

  /* read the bitmap */
  for(index = (b->height - 1) * b->width; index >= 0; index -= b->width)
    for(x=0; x < b->width; x++)
      b->data[(word) index + x] = (byte) fgetc(fp);

  fclose(fp);
}

多少嫌になってきますが(苦笑)、これは多分に C 言語の不便さに起因するので我慢するしかありません。最初にファイルの先頭からデータを読んで、'BM' という識別子で BMP ファイルなのかをチェックしています。サイズを示すヘッダーまでデータを読み飛ばし、横と縦のサイズをそれぞれ読み取って構造体に格納しています。サイズは本来は DWORD なのですが非現実的に巨大なサイズとなるので最下位の 1 バイト分だけ(すなわち最大 255 ピクセル)を読んで後は無視しています。

そして、パレットの色数を読み込んでいますが、このサンプルでは色数は 8-bit(256 色)とみなして、結局無視します。なので「if (num_colors == 0) num_colors = 256;」の部分は、単に「num_colors = 256;」しても同じです。ちなみに本来はパレット色数が 0 となるのは、パレットデータが存在しない場合のようです(参考:BMP形式(Windows DIB))。

そして、横×縦の分のサイズのメモリー領域を確保し、その領域の先頭アドレスへのポインターを構造体に格納します。

さらに、色数× 4 の分の領域が色パレットとして存在するので、スキップし、ついにビットマップデータの先頭へと辿り着きます。ここでも行ごとに左か右へとピクセルのデータを読み込んでいます。ここで留意することは、行の読み方が、ビットマップファイルと、メモリーへの格納の仕方で順序を反対にしている点でしょうか。これはおそらく、ビットマップファイルのフォーマットがそのようになっている(下から上にスキャンラインが積み重なるようになっている)からだと思います(参考:BMP形式(Windows DIB))。

void draw_bitmap(BITMAP *bmp, int x, int y) {
  int j;
  word screen_offset = (y << 8) + (y << 6) + x;
  word bitmap_offset = 0;

  for (j = 0; j < bmp->height; j++) {
    memcpy(&VGA[screen_offset], &bmp->data[bitmap_offset], bmp->width);

    bitmap_offset += bmp->width;
    screen_offset += SCREEN_WIDTH;
  }
}

最初に掲げた簡単なサンプルコードのやり方と少し違っていて、メモリーに格納されたビットマップのデータの一行分を丸ごと一気に memcpy で VRAM にコピーしています。

void draw_transparent_bitmap(BITMAP *bmp, int x, int y) {
  int i, j;
  word screen_offset = (y << 8) + (y << 6);
  word bitmap_offset = 0;
  byte data;

  for (j = 0; j < bmp->height; j++) {
    for(i = 0; i < bmp->width; i++, bitmap_offset++) {
      data = bmp->data[bitmap_offset];
      if (data) VGA[screen_offset + x + i] = data;
    }
    screen_offset += SCREEN_WIDTH;
  }
}

こちらは透明部分を持つビットマップの描写ルーチンです。こちらについては、ピクセル毎に色を判別して、透明化するべきピクセルなのかどうなのか判断しなければならないので、一行分を一気に扱うわけにはいきません。従って、冒頭に掲げた簡単なサンプルコードのような二重の for ループで一行ずつずらしながら、左から右に点をプロットしています。このルーチンでは、色番号 0 の場合に透明になります。というか恐らく、透明となる色はパレット番号 0 番とするのがビットマップファイルのフォーマットなのでしょう。

ビットマップの描画に関しては以上で終わりです。

wait()

描画以外に、システムクロックを利用した wait() 関数がこのサンプルには含まれています。

void wait(int ticks) {
  word start;

  start = *my_clock;

  while (*my_clock-start < ticks) {
    *my_clock = *my_clock;              /* this line is for some compilers
                                         that would otherwise ignore this
                                         loop */
  }
}

ここでは特に深い意味はなく、最初に背景を描画した後、少し間を置いて透明化しないビットマップを描画し、さらに少し間を置いて透明化するビットマップを描画するという段取りを実現するためですね。ちなみに背景は、ビットマップではなく main() の中で普通に色をループさせつつ一行ずつ描画しています。

パレット操作

そしてパレット操作。さらに頭を悩ますトピックが続きますが、パレットは必ずしも使いこなさなくてもデフォルトのパレットでどうにかするのなら大丈夫なので、多少雑に理解したところで問題はないと考えて、気負わずにこなしていきましょう。

画面モード 0x13 の 256 色というのは、同時発色数が 256 色という意味で、この同時発色する 256 色の組み合せがパレットとなります。Brackeen さんの説明によると、VGA 規格においては、RGB 各色 6-bit(64 階調)すなわち 26 × 26 × 26 = 262144 通りの色表現が可能です。一方、ビットマップファイルの側は各色 8-bit(256 階調)で色が表現されるので、すなわち、色の階調を 256 から 64 にダウングレードする操作(1/4 にする、すなわち、2 回右ビットシフトする)がプログラムにとって必要となります。そしてダウングレードしたデータを VGA に I/O ポートを通じて与えます。

outp(0x03c8, index);
outp(0x03c9, red);
outp(0x03c9, green);
outp(0x03c9, blue);

このように、まずポート 0x03c8 にパレットの色番号(index)を通知して、次に 0x03c9 に RGB の順番でデータ(0 〜 63 の数値)を与えます。また、パレットの全色を一気にセットする場合は次のように、パレットの色番号 0 を通知してから順に RGB のデータを与えます。色番号を都度通知する必要はありません。

outp(0x03c8, 0);
for(i = 0; i < 256; i++) {
  outp(0x03c9, palette_red[i]);
  outp(0x03c9, palette_green[i]);
  outp(0x03c9, palette_blue[i];
}

palette.c

それでは Brackeen さんのサンプルプログラムの内容を見ていきましょう。

typedef struct tagBITMAP {            /* the structure for a bitmap. */
  word width;
  word height;
  byte palette[256 * 3];
  byte *data;
} BITMAP;

ビットマップデータを格納するためのメモリー構造体の定義では、パレットを格納する領域が追加されています。

void load_bmp(char *file,BITMAP *b) {

省略

  /* read the palette information */
  for (index = 0; index < num_colors; index++) {
    b->palette[(int) (index * 3 + 2)] = fgetc(fp) >> 2;
    b->palette[(int) (index * 3 + 1)] = fgetc(fp) >> 2;
    b->palette[(int) (index * 3 + 0)] = fgetc(fp) >> 2;
    x = fgetc(fp);
  }

省略

}

パレットを読むための for ループが追加されています。これも留意する点は、ビットマップファイルのフォーマットでは、RGB とは逆の BGR の順番で格納されているという点です。読んだ値を 1/4 して構造体に格納しているのがわかります。また、4 バイト目は予約領域で実際には常に 0 が格納されています(参考:BMP形式(Windows DIB))。要するに、4 バイト目のデータは捨てることになります。ここでは「x = fgetc(fp)」としてダミー的に代入していますが、「fskip(fp, 1)」でスキップしても同じことになります。

void set_palette(byte *palette) {
  int i;

  outp(PALETTE_INDEX, 0);              /* tell the VGA that palette data
                                         is coming. */
  for (i = 0; i < 256 * 3; i++)
    outp(PALETTE_DATA, palette[i]);    /* write the data */
}

パレットの色番号を 0 にセットした後、構造体の中の全色分のパレットデータを頭から順にポートに出力しています。

void rotate_palette(byte *palette) {
  int i, red, green, blue;

  red   = palette[3];
  green = palette[4];
  blue  = palette[5];

  for(i = 3; i < 256 * 3 - 3; i++)
    palette[i] = palette[i + 3];

  palette[256 * 3 - 3] = red;
  palette[256 * 3 - 2] = green;
  palette[256 * 3 - 1] = blue;

  set_palette(palette);
}

これは単にパレットの色番号を一つずらして VGA のポートにセットし直しています。3 だけ配列の添字をずらしているのは、パレットの色番号 0 のパレットは透明化するための特殊な色番号なので、除外するためでしょう。

描画に関する部分は以上ですが、このコードには画面の書き換えタイミングとの同期に関するルーチンも含まれています。

垂直同期

先程のサンプルプログラムでパレットの書き換えを普通に行うと、画面のリフレッシュレートよりも速くパレットの変更が行なわれてしまうため、色が縞模様になってしまうという現象が発生する可能性があります。つまり、画面が上から下に走査線の移動に従って書き換えられている途中で、パレットが変更された場合です。僕の環境では DOSEMU が原因なのか、それとも DVI 出力が原因なのかわかりませんが、残念ながら確認できませんでしたが、この問題は、パレットの書き換え以外でも画面のチラツキの防止と関係している問題なので、比較的よく聞く話です。

この場合は、ポート 3DA 番地の第 3 ビット(最下位ビットから 4 桁目)をチェックして対処します。画像が書換中の場合は、このビットが 0 になっており、書換が済んでいて電子銃の描画位置が右下から左上に戻っている最中の場合は 1 になっています。他のビットに関しては無関係です。すなわち、
????0??? の時は書換中で、
????1??? の時は書換済なので、
00001000 と論理積(AND)を計算すれば、
0 と AND されたビットは ? が 1 であろうが 0 であろうが結局は 0 となり、1 と AND されたビットは 1 であれば 1、0 であれば 0 となるので、結局第 3 ビットが 1 の場合は論理積の結果が 00001000(= 8)となり、0 の場合は論理積の結果が 00000000(= 0)となることから、条件判定を使って状態を調べることができるのです。Brackeen さんのサンプルプログラムでは以下のようにしています。

void wait_for_retrace(void) {
    /* wait until done with vertical retrace */
    while ( (inp(INPUT_STATUS) & VRETRACE)) {};
    /* wait until done refreshing */
    while (!(inp(INPUT_STATUS) & VRETRACE)) {};
}

INPUT_STATUS と VRETRACE はそれぞれマクロ定義で 0x03DA と 0x08 になっています。最初の while ループでは第 3 ビットが 0 になるのを待っています。次に第 3 ビットが 1 になってから、この wait_for_retrace() 関数を抜けて、パレットを変更するなり VRAM を書き換えるなりする次の処理へと移ることになります。つまり、第 3 ビットが 1 の時に書き換えをしたいわけですが、最初に一度第 3 ビットが 0 になるのを待つのは、第 3 ビットが最初から 1 の場合は、途中からの場合があるため、電子銃が右下から左上に移動する時間を目一杯使えるタイミングをつかむためです。

以上でビットマップとパレットの操作については終わりました。

マウス対応とアニメーション

次はテキストの「Mouse Support & Animation」です。マウスについては、ゲームでは必ずしも必要とはしませんが、コンシューマ等の他のゲームプラットフォームにはない、PC ならではの操作デバイスですし、もののついでに勉強してみたいと思います。基本的にこの章では、マウスとアニメーションがセットで説明されています。つまり、マウスの操作に合わせて、ボタンをアニメーションで変化させようというものです。

マウスの認識

まず最初にマウスが存在するかどうかの認識方法について説明されています。これは非常に簡単で、AX レジスターに 0 をセットして割り込み 0x33 をコールするだけです。結果としてマウスが存在していた場合には、AX レジスターに 0xFFFF が返され、BX レジスターにはマウスのボタンの数が返されます。

union REGS regs;

regs.x.ax = 0;
int86(0x33, &regs, &regs);
mouse_on    = regs.x.ax;
num_buttons = regs.x.bx;

またこのファンクションコールによって、マウスポインターの位置が画面の真ん中にセットされます。また、マウスドライバー自身が認識するこの画面の真ん中の座標は、(320,100) となりますが、これはマウスの位置を示す座標系自体が画面とは独立していて 640x200 で表わされる系のためです。

次に、マウスの現在位置を調べるには、AX レジスターに 3 をセットして割り込み 0x33 をコールします。そして CX レジスターと DX レジスターにそれぞれ X, Y 座標が返され、BX レジスターには、下位ビットから(左・右・中ボタンの)順にビットの on/off によってボタンの on/off が返されます。すなわち以下のようにすれば、マウスの位置に点を描画することができます。

regs.x.ax = 3;
int86(0x33, &regs, &regs);
x = regs.x.cx / 2;
y = regs.x.dx;
VGA[y * 320 + x] = 15;

以上のように、マウスの位置を扱うことは簡単ですが、このやり方は、320x240 や 640x480 といった、マウス座標系と縦横比の違う画面モードの場合にも通用するやり方ではありません。そこで Brackeen さんは次に、AX レジスターに 0xB をセットして割り込み 0x33 をコールする方法を紹介しています。この場合に返される値は、マウスのマウス座標系における現在位置ではなく、X 方向に動いた量と Y 方向に動いた量がそれぞれ CX レジスターと DX レジスターに返されます。

reg.x.ax = 0x0B;
int86(0x33, &regs, &regs);  
x += (int) reg.x.cx;
y += (int) reg.x.dx;

次に説明されているのは、マウスボタンについてです。ボタンが今押され続けているのかそれともそうではないのかということが必要なく、ただ、一度押されたかどうか押されてから離されたかどうかということを検知するには、AX レジスターに 5 と 6 をセットして割り込み 0x33 をコールするやり方を使えばいいということです。この時、BX レジスターには状態を知りたいボタンの番号をセットします。5 をセットするファンクションコールの場合は BX レジスターには、最後にこのファンクションがコールされてからの間にこのボタンが押された回数が返されます。ちなみに、AX レジスターに返されるのは、3 のファンクションコールのときの BX レジスターに返されていた各ボタンの状態のビットパターンです。6 のファンクションコールのときは、BX レジスターに返される値としてカウントされている回数がボタンを離した回数で、BX レジスターの扱いは 5 のファンクションコールと全く同じで、押されているボタンに該当するビットが on (1) になります。

while (1) {
  regs.x.ax = 5;
  regs.x.bx = 0;
  int86(0x33, &regs, &regs);
  if (regs.x.bx)
    printf("Left button pressed.\n");
  regs.x.ax = 6;
  regs.x.bx = 0;
  int86(0x33, &regs, &regs);
  if (regs.x.bx)
    printf("Left button released.\n");
}

つまり、カウントされた回数が一度でも発生すれば、それを検出して printf しているのがわかります。

mouse.c

いよいよこの章のメインテーマにやってきました。このサンプルプログラムでは、マウスのクリックを検出して、押されたボタンとマウスカーソルの画像を変化(アニメーション)させます。そのためにマウスの解説がされていたわけですが、このサンプルプログラムではむしろ、一枚の画像ファイルにまとめられたデータの中から個々の画像に分けて使い分けているテクニックも重要だと思います。

#define MOUSE_INT           0x33
#define MOUSE_RESET         0x00
#define MOUSE_GETPRESS      0x05
#define MOUSE_GETRELEASE    0x06
#define MOUSE_GETMOTION     0x0B
#define LEFT_BUTTON         0x00
#define RIGHT_BUTTON        0x01
#define MIDDLE_BUTTON       0x02

#define MOUSE_WIDTH         24
#define MOUSE_HEIGHT        24
#define MOUSE_SIZE          (MOUSE_HEIGHT*MOUSE_WIDTH)
#define BUTTON_WIDTH        48
#define BUTTON_HEIGHT       24
#define BUTTON_SIZE         (BUTTON_HEIGHT*BUTTON_WIDTH)
#define BUTTON_BITMAPS      3
#define STATE_NORM          0
#define STATE_ACTIVE        1
#define STATE_PRESSED       2
#define STATE_WAITING       3

#define NUM_BUTTONS         2
#define NUM_MOUSEBITMAPS    9

まずはマウス関連のマクロ定義と、画像サイズに関する定義などが大量に加わっています。

typedef unsigned long  dword;
typedef short sword;                  /* signed word */

型定義の別名も DWORD と SWORD が加えられています。

typedef struct tagMOUSEBITMAP MOUSEBITMAP;
struct tagMOUSEBITMAP {
  int hot_x;
  int hot_y;
  byte data[MOUSE_SIZE];
  MOUSEBITMAP *next;   /* points to the next mouse bitmap, if any */
};

typedef struct {           /* the structure for a mouse. */
  byte on;
  byte button1;
  byte button2;
  byte button3;
  int num_buttons;
  sword x;
  sword y;
  byte under[MOUSE_SIZE];
  MOUSEBITMAP *bmp;

} MOUSE;

typedef struct {           /* the structure for a button. */
  int x;
  int y;
  int state;
  byte bitmap[BUTTON_BITMAPS][BUTTON_SIZE];

} BUTTON;

MOUSEBITMAP はマウスの画像のための構造体です。マウスポインターがアニメーションする場合のデータを格納するためのもののようです。MOUSE はレジスターから読み取ったマウスの状態などの情報を格納する構造体。そして BUTTON はボタンのオブジェクトの状態を格納する構造体。

void get_mouse_motion(sword *dx, sword *dy) {
  union REGS regs;

  regs.x.ax = MOUSE_GETMOTION;
  int86(MOUSE_INT, &regs, &regs);
  *dx = regs.x.cx;
  *dy = regs.x.dx;
}

マウスの動きを読み取るための関数です。マウスが存在するかどうかを調べる関数 init_mouse() よりも先に記述されているのは、init_mouse() の中でこの関数を利用しているからです。

sword init_mouse(MOUSE *mouse) {
  sword dx, dy;
  union REGS regs;

  regs.x.ax = MOUSE_RESET;
  int86(MOUSE_INT, &regs, &regs);
  mouse->on          = regs.x.ax;
  mouse->num_buttons = regs.x.bx;
  mouse->button1     = 0;
  mouse->button2     = 0;
  mouse->button3     = 0;
  mouse->x           = SCREEN_WIDTH  / 2;
  mouse->y           = SCREEN_HEIGHT / 2;
  get_mouse_motion(&dx, &dy);
  return mouse->on;
}

マウスが存在するかどうか調べたりする関数です。

sword get_mouse_press(sword button) {
  union REGS regs;

  regs.x.ax = MOUSE_GETPRESS;
  regs.x.bx = button;
  int86(MOUSE_INT, &regs, &regs);
  return regs.x.bx;
}

sword get_mouse_release(sword button) {
  union REGS regs;

  regs.x.ax = MOUSE_GETRELEASE;
  regs.x.bx = button;
  int86(MOUSE_INT, &regs, &regs);
  return regs.x.bx;
}

ボタンが押されたのと開放されたのをそれぞれ調べる関数です。

void show_mouse(MOUSE *mouse) {
  int x, y;
  int mx = mouse->x - mouse->bmp->hot_x;
  int my = mouse->y - mouse->bmp->hot_y;
  long screen_offset = (my << 8) + (my << 6);
  word bitmap_offset = 0;
  byte data;

  for (y = 0; y < MOUSE_HEIGHT; y++) {
    for (x = 0; x < MOUSE_WIDTH; x++, bitmap_offset++) {
      mouse->under[bitmap_offset] = VGA[(word) (screen_offset + mx + x)];
      /* check for screen boundries */
      if (mx + x < SCREEN_WIDTH  && mx + x >= 0 &&
          my + y < SCREEN_HEIGHT && my + y >= 0) {
        data = mouse->bmp->data[bitmap_offset];
        if (data) VGA[(word) (screen_offset + mx + x)] = data;
      }
    }
    screen_offset += SCREEN_WIDTH;
  }
}

今回のサンプルプログラムは急激にコードの量が増えているので嫌になってきますが、ここが山場なのでめげずに追っていきます。最初に mx や my という値を mouse->x や mouse->y から mouse->bmp->hot_x や mouse->bmp->hot_y を引いて算出していますが、これはマウスのビットマップ画像の中のホットスポット(マウスポインタの矢印の先端)が画像の左上からずれている場合には、その分、実際のマウスの位置よりもその分上側・左側からポインタの画像を描画しなければなりません。すなわち、mx と my は、マウスの画像の左上の座標を算出していることになります。

そして二重の for ループは、マウスポインタの画像の高さと幅の分をループしているので、ポインタの画像の描画であることがわかります。ループ内の最初の「mouse->under[bitmap_offset] = VGA[(word) (screen_offset + mx + x)];」ですが、Brackeen さんも説明していない部分なので最初は何なのか悩みましたが、これはマウスポインタが重なることによって消される部分の背景の色データをピクセル毎にバックアップしています。このことによって、マウスポインタが通り過ぎた後で、背景全体を描き直すことなく、消された部分だけをピンポイントで描き直すことが可能になるわけです。

そして次の if ブロックは、マウスポインタの画像の描画しようとしているピクセルが、画面の枠からはみ出した部分でないかどうかをチェックしています。はみ出していた場合は、描画をスキップすることになります。はみ出した部分をちゃんとスキップするようにしないと、VRAM として割り当てられている範囲外のメモリー領域に書き込んでしまうことになるので、不要な処理をスキップするというだけの目的ではなく、ここはきっちりとこうしておく必要があるわけです。

そして描くべき部分のマウスポインタの画像のピクセルの色データを調べ、それが透明でなければ、VRAM のデータを書き換えます。

void hide_mouse(MOUSE *mouse) {
  int x, y;
  int mx = mouse->x - mouse->bmp->hot_x;
  int my = mouse->y - mouse->bmp->hot_y;
  long screen_offset = (my << 8) + (my << 6);
  word bitmap_offset = 0;

  for (y = 0; y < MOUSE_HEIGHT; y++) {
    for (x = 0; x < MOUSE_WIDTH; x++, bitmap_offset++) {
      /* check for screen boundries */
      if (mx + x < SCREEN_WIDTH  && mx + x >= 0 &&
          my + y < SCREEN_HEIGHT && my + y >= 0) {
        VGA[(word) (screen_offset + mx + x)] = mouse->under[bitmap_offset];
      }
    }
    screen_offset += SCREEN_WIDTH;
  }
}

次はマウスポインタで消された背景を元に戻すための関数です。先程の show_mouse() 関数でバックアップした背景部分のピクセルデータを書き戻しています。

ソースコードのコメントによると、show_mouse() と hide_mouse() は最適化されていないと書かれています。これは、マウスポインタの画像がある部分の 24x24 の領域全体の背景のデータがバックアップされ、全体が書き戻されるというアルゴリズムになっている点を指しているのでしょうか? つまり、透明部分は元々描画されていないので、その部分についてはバックアップ・書き戻しは必要ありません。なので、show_mouse() で色データをバックアップする場合に、透明部分でバックアップの必要のない部分については、透明を色番号 0 として記憶しておき、書き戻す場合に、透明部分については書き戻しの必要がないと判断してスキップするアルゴリズムにすればいいのだろうと思います。そうすると上記 2 つの関数はこんな感じになります:

void show_mouse(MOUSE *mouse) {
  int x, y;
  int mx = mouse->x - mouse->bmp->hot_x;
  int my = mouse->y - mouse->bmp->hot_y;
  long screen_offset = (my << 8) + (my << 6);
  word bitmap_offset = 0;
  byte data;

  for (y = 0; y < MOUSE_HEIGHT; y++) {
    for (x = 0; x < MOUSE_WIDTH; x++, bitmap_offset++) {
      /* check for screen boundries */
      if (mx + x < SCREEN_WIDTH  && mx + x >= 0 &&
          my + y < SCREEN_HEIGHT && my + y >= 0) {
        data = mouse->bmp->data[bitmap_offset];
        if (data) {
          mouse->under[bitmap_offset] = VGA[(word) (screen_offset + mx + x)];
          VGA[(word) (screen_offset + mx + x)] = data;
        } else {
          mouse->under[bitmap_offset] = 0;
        }
      }
    }
    screen_offset += SCREEN_WIDTH;
  }
}

void hide_mouse(MOUSE *mouse) {
  int x, y;
  int mx = mouse->x - mouse->bmp->hot_x;
  int my = mouse->y - mouse->bmp->hot_y;
  long screen_offset = (my << 8) + (my << 6);
  word bitmap_offset = 0;

  for (y = 0; y < MOUSE_HEIGHT; y++) {
    for (x = 0; x < MOUSE_WIDTH; x++, bitmap_offset++) {
      /* check for screen boundries */
      if (mx + x < SCREEN_WIDTH  && mx + x >= 0 &&
          my + y < SCREEN_HEIGHT && my + y >= 0) {
        if (mouse->under[bitmap_offset]) VGA[(word) (screen_offset + mx + x)] = mouse->under[bitmap_offset];
      }
    }
    screen_offset += SCREEN_WIDTH;
  }
}

次で main() 以外の関数定義は最後になります。

void draw_button(BUTTON *button) {
  int x, y;
  word screen_offset = (button->y << 8) + (button->y << 6);
  word bitmap_offset = 0;
  byte data;

  for (y = 0; y < BUTTON_HEIGHT; y++) {
    for (x = 0; x < BUTTON_WIDTH; x++, bitmap_offset++) {
      data = button->bitmap[button->state % BUTTON_BITMAPS][bitmap_offset];
      if (data) VGA[screen_offset + button->x + x] = data;
    }
    screen_offset += SCREEN_WIDTH;
  }
}

マウスでクリックするボタンを描画するための関数です。「button->state % BUTTON_BITMAPS」がちょっと理解に苦しみますが、これはボタンの画像は 0-2 の 3 種類あって、一方、マウスのボタンの状態は 0-3 の 4 種類あり、0-2 については順当に割り当てられるのですが、余った 3 の状態を画像の 0 に割り当てるためだと思います。そしてこの行全体では、現在のマウスボタンの状態に相応しい画像の中の該当部分のピクセルのデータを読み出しているわけです。そして次の行で、透明でない場合は VRAM を書き換えます。

そして次でやっと main() に辿り着きます。マウスポインターが円形の時計のようなものになって回転するアニメーションに関するルーチンがまだ登場していませんので、これは main() で処理しているのだろうと思われますが、はたしていかがでしょうか? これから見ていきたいと思います。はい、ドン!:

void main() {
  BITMAP bmp;
  MOUSE  mouse;
  MOUSEBITMAP *mb[NUM_MOUSEBITMAPS], *mouse_norm, *mouse_wait, *mouse_new=NULL;
  BUTTON *button[NUM_BUTTONS];
  word redraw;
  sword dx = 0, dy = 0, new_x, new_y;
  word press, release;
  int i, j, done = 0, x, y;
  word last_time;

  if (__djgpp_nearptr_enable() == 0) {
    printf("Could get access to first 640K of memory.\n");
    exit(-1);
  }

  VGA += __djgpp_conventional_base;
  my_clock = (void *) my_clock + __djgpp_conventional_base;

  for (i=0; i < NUM_MOUSEBITMAPS; i++) {
    if ((mb[i] = (MOUSEBITMAP *) malloc(sizeof(MOUSEBITMAP))) == NULL) {
      printf("Error allocating memory for bitmap.\n");
      exit(1);
    }
  }

  for (i = 0; i < NUM_BUTTONS; i++) {
    if ((button[i] = (BUTTON *) malloc(sizeof(BUTTON))) == NULL) {
      printf("Error allocating memory for bitmap.\n");
      exit(1);
    }
  }
  mouse_norm = mb[0];
  mouse_wait = mb[1];

  mouse.bmp = mouse_norm;

  button[0]->x     = 48;              /* set button states */
  button[0]->y     = 152;
  button[0]->state = STATE_NORM;

  button[1]->x     = 224;
  button[1]->y     = 152;
  button[1]->state = STATE_NORM;

  if (!init_mouse(&mouse)) {          /* init mouse */
    printf("Mouse not found.\n");
    exit(1);
  }

  load_bmp("images.bmp", &bmp);       /* load icons */
  set_mode(VGA_256_COLOR_MODE);       /* set the video mode. */


  for (i = 0; i < NUM_MOUSEBITMAPS; i++)     /* copy mouse pointers */
    for (y = 0; y < MOUSE_HEIGHT; y++)
      for (x = 0; x < MOUSE_WIDTH; x++) {
        mb[i]->data[x + y * MOUSE_WIDTH] = bmp.data[i * MOUSE_WIDTH + x + y * bmp.width];
        mb[i]->next = mb[i + 1];
        mb[i]->hot_x = 12;
        mb[i]->hot_y = 12;
      }

  mb[0]->next  = mb[0];
  mb[8]->next  = mb[1];
  mb[0]->hot_x = 7;
  mb[0]->hot_y = 2;
                                      /* copy button bitmaps */
  for (i = 0; i < NUM_BUTTONS; i++)
    for (j = 0; j < BUTTON_BITMAPS; j++)
      for (y = 0; y < BUTTON_HEIGHT; y++)
        for (x = 0; x < BUTTON_WIDTH; x++) {
          button[i]->bitmap[j][x + y * BUTTON_WIDTH] =
            bmp.data[i * (bmp.width >> 1) + j * BUTTON_WIDTH + x + (BUTTON_HEIGHT + y) * bmp.width];
        }

  free(bmp.data);                     /* free up memory used */

  set_palette(bmp.palette);

  for (y = 0; y < SCREEN_HEIGHT; y++)        /* display a background */
    for (x = 0; x < SCREEN_WIDTH; x++)
      VGA[(y << 8) + (y << 6) + x] = y;

  new_x = mouse.x;
  new_y = mouse.y;
  redraw = 0xFFFF;
  show_mouse(&mouse);
  last_time = *my_clock;
  while (!done) {                     /* start main loop */
    if (redraw) {                     /* draw the mouse only as needed */
      wait_for_retrace();
      hide_mouse(&mouse);
      if (redraw > 1) {
        for (i = 0; i < NUM_BUTTONS; i++)
          if (redraw & (2 << i)) draw_button(button[i]);
      }
      if (mouse_new != NULL) mouse.bmp = mouse_new;
      mouse.x = new_x;
      mouse.y = new_y;
      show_mouse(&mouse);
      last_time = *my_clock;
      redraw = 0;
      mouse_new = NULL;
    }

    do {                              /* check mouse status */
      get_mouse_motion(&dx, &dy);
      press   = get_mouse_press(LEFT_BUTTON);
      release = get_mouse_release(LEFT_BUTTON);
    } while (dx == 0 && dy == 0 && press == 0 && release == 0 && *my_clock == last_time);

    if (*my_clock != last_time) {     /* check animation clock */
      if (mouse.bmp != mouse.bmp->next) {
        redraw = 1;
        mouse.bmp = mouse.bmp->next;
      } else {
        last_time = *my_clock;
      }
    }

    if (press)   mouse.button1 = 1;
    if (release) mouse.button1 = 0;

    if (dx || dy) {                   /* calculate movement */
      new_x = mouse.x + dx;
      new_y = mouse.y + dy;
      if (new_x < 0)   new_x = 0;
      if (new_y < 0)   new_y = 0;
      if (new_x > 319) new_x = 319;
      if (new_y > 199) new_y = 199;
      redraw = 1;
    }

    for (i = 0; i < NUM_BUTTONS; i++) {      /* check button status */
      if (new_x >= button[i]->x && new_x < button[i]->x + 48 &&
          new_y >= button[i]->y && new_y < button[i]->y + 24) {
        if (release && button[i]->state == STATE_PRESSED) {
          button[i]->state = STATE_ACTIVE;
          redraw |= (2 << i);
          if (i == 0) {
            if (mouse.bmp == mouse_norm)
              mouse_new = mouse_wait;
            else
              mouse_new = mouse_norm;
          } else if (i == 1) {
            done = 1;
          }
        }
        else if (press) {
          button[i]->state = STATE_PRESSED;
          redraw |= (2 << i);
        } else if (button[i]->state == STATE_NORM && mouse.button1 == 0) {
          button[i]->state = STATE_ACTIVE;
          redraw |= (2 << i);
        } else if (button[i]->state == STATE_WAITING) {
          if (mouse.button1) {
            button[i]->state = STATE_PRESSED;
          } else {
            button[i]->state = STATE_ACTIVE;
          }
          redraw |= (2 << i);
        }
      } else if (button[i]->state == STATE_ACTIVE) {
        button[i]->state = STATE_NORM;
        redraw |= (2 << i);
      } else if (button[i]->state == STATE_PRESSED && mouse.button1) {
        button[i]->state = STATE_WAITING;
        redraw |= (2 << i);
      } else if (button[i]->state == STATE_WAITING && release) {
        button[i]->state = STATE_NORM;
        redraw |= (2 << i);
      }
    }
  }                                   /* end while loop */

  for (i = 0; i < NUM_MOUSEBITMAPS; i++) { /* free allocated memory */
    free(mb[i]);
  }

  for (i = 0; i < NUM_BUTTONS; i++) {      /* free allocated memory */
    free(button[i]);
  }

  set_mode(TEXT_MODE);                /* set the video mode back to
                                         text mode. */
  __djgpp_nearptr_disable();

  return;
}

まず最初の方では、マウスポインターとボタンに関する画像データを、一つ一つに分けて構造体に格納しています。メモリー領域を用意してから、ビットマップファイルを読み込んでいます。丸い時計のマウスポインターの場合はマウスのホットスポットが (12, 12) ですが、矢印のポインターの場合は (7,2) です。切り分けが終ったら、ビットマップファイルのデータ用の領域は必要がなくなったので開放しています。それからパレットをセットしています。そして、背景を描画します。

この先からが、主にマウスポインターに関する描画部分です。最終的に終了判定がされるまで、while ループでループし続けます。

まず最初の if ブロックで、再描画フラグが立っている場合のみ、マウスポインターを描き直しています。はじめにマウスによって隠されていた背景を描き直します。さらに再描画フラグの値が正の数の場合は、マウスポインターがボタンの上にあるということなので、ボタンの画像を描き直します。そしてマウスポインターの状態が位置が変化している場合には、画像を時計のものとしておきます。その上で、マウスの X, Y 座標を新しいものに修正して、マウスを再描画します。再描画が終ったので、再描画フラグは 0 に戻します。またマウスポインターが入れ替えが済んでいるので、mouse_new を NULL にしておきます。

次に、do while ループでマウスの動きを調べます。動きやボタンに変化がなく、また時計のアニメーションを変化させる必要もない場合は、マウスが操作されるまでこのループで待ちます。

次の if ループは、時計のアニメーションを変化させる場合で、マウスポインターの画像を次の画像に変更するように mouse 構造体の属性を書き換えています。また次の 2 つの if ブロックでボタンの押し・離しに応じて、mouse 構造体の属性をスイッチしています。

次の if ループではマウスが移動した場合に新しいマウスポインターの位置を算出していますが、画面からはみ出す場合には、画面の端で止まるように扱ってから、再描画フラグを立てています。

そして次の大きな for ループでは、ボタンの状態を調べています。for ループはボタンが複数ある場合にそれぞれについて調べるために設けられていますが、その内側は if 〜 else if ブロックで条件分岐されています。分岐は、「ボタンの上にマウスポインターがある場合」「マウスポインターが上にないのに、ボタンの状態が「アクティブ」になっている場合(→状態を「通常」に戻す)」「マウスポインターが上にないのに、ボタンの状態が「押された」になっている場合(→状態を「待ち」に戻す)」「マウスポインターが上にないのに、ボタンの状態が「待ち」になっている場合(→状態を「通常」に戻す)」となっています。「待ち」という状態があるのは、ボタンの上でマウスを押して押したままマウスを動かしてボタンから離れるような場合を想定しているからです。ボタンの上で押して、(途中で押したままボタンから離れても構わないが)ボタンの上で離した場合に、ボタンのスイッチが入ったと判断されます。

このように組み合わせの条件分岐を記述しているために、ここの for ループは非常に巨大なものとなっているので、後からコードだけを読んで解読するのは結構骨が折れますが、自分でスクラッチで一つ一つ試しながらコードを書く場合はどうにかなると思います。

以上で、このテキストの山となるマウスとアニメーションの章が終わりました。次の章で最後です。次の章は、画面に大量のオブジェクトを描画する場合にどうしても発生してしまうチラツキを回避するために必須のテクニックとなる、ダブルバッファとページフリップ、そして非連鎖モードについて解説されています。

ダブルバッファとページフリップ、そして非連鎖モード

次はテキストの「Double Buffering, Page Flipping, & Unchained Mode」です。ダブルバッファとページフリップについては現在でも常識的に使われている技術だと思います。非連鎖モードというのは、あまり聞きませんが、現在ではドライバーレベルで行われていて普通はプログラマがタッチするものではないと思います。ただ、0x13 モード以外のモードを利用しようとなると、必要な場合が生じるかもしれません。

この章において Brackeen さんは、ダブルバッファとページフリップの違いについて説明しています。描画すべきオブジェクトが多量で、一回の垂直同期における空白期間に描画が間に合わない場合、そのままだと画面にチラツキが生じます。ダブルバッファもページフリップもどちらもそのチラツキに対する防止策として機能する点は同じです。

ダブルバッファの場合は、メインメモリーの側に、VRAM と同じ容量のバッファ領域を設け、そこにオブジェクトを描画し、すべてのオブジェクトを描画し終ったら、一気にメモリーの内容を VRAM に転送コピーします。

unsigned char *double_buffer;

...

double_buffer = (unsigned char *) malloc(320 * 200);
if (double_buffer == NULL) {
  printf("Not enough memory for double buffer.\n");
  exit(1);
}

バッファ分のメモリーを確保し、

double_buffer[(y << 8) + (y << 6) + x] = color;

オブジェクトの描画はバッファに対して行います。そして、すべてのオブジェクトの更新が終ったら、

while ( (inp(INPUT_STATUS_1) & VRETRACE));
while (!(inp(INPUT_STATUS_1) & VRETRACE));
memcpy(VGA, double_buffer, 320 * 200);

memcpy で一気に VRAM 領域にコピーします。このコピーは、ちょうど垂直同期の空白期間に行うのがポイントです。

ページフリップの場合、このバッファとして描くための領域が VRAM の側に設けられています。つまり画面を描画するのに最低限必要なメモリーの 2 倍以上の容量を VRAM として持っていて、その VRAM の中のアドレス的にどの範囲を実際に画面として出力すればいいのかという指示を VGA に対してレジスターを操作して与えます。つまり、広い VRAM のうちのある一部の領域が「窓」として表示用に使われ、その窓枠を動かすことによって、バッファ領域を交代するのです。

ところが、VGA 規格上、0x13 モードで利用できる VRAM の容量は、物理的な搭載量にかかわらず、64KB だけです。つまり窓用の容量ですべて使い切ってしまっており、バッファとして使うために必要なあと 64KB の容量がありません。

しかし実は、VGA 規格自体では最大 256KB まで VRAM を扱うことができます。そして 0x13 モードでも、文書化されていない事実として、256KB の VRAM にアクセスできる方法があります。この場合は、非連鎖モードという他のモードで使われている特殊な VRAM アクセス方法を使います。むしろ、VGA 規格では 64KB ずつのプレーンを 4 プレーン搭載することを想定しており、4 プレーン全てを利用した場合は結果的に 256KB の容量を利用できることになりますが、その場合はどうやっても非連鎖モードを使うことになります。そして 0x13 モードではデフォルトでは連鎖モードとして規格されており、連鎖モードで利用するためには 1 プレーンしか使えないので、最大 64KB となってしまうのです。

すなわち、0x13 モードでも、VRAM のアクセス方式だけを他のモードと同様の非連鎖モードに切り替えることはレジスターの操作によって可能であり、結果的に画面の解像度は 0x13 のまま、VRAM を 256KB 利用することが可能です。これによって、ページフリップが実現できるわけです。

この非連鎖モードというのは、VRAM のアクセススピードを向上させるために、座標と VRAM との割り付けの関係が特殊な形になっています。256KB の VRAM を 4 つのプレーンに分けて、そのプレーンにまたがるような形で、VRAM の番地が割り付けられています。おそらく、並行アクセスによる読み出しスピードを向上させるためだと思います(ex. HDD の RAID におけるディスクストライピング)。そのために結構、扱いが複雑です。

また、Brackeen さんがサンプルプログラム unchain.c によってベンチマークしていますが、ダブルバッファよりもページフリップの方がパフォーマンス的に速いのは描画するオブジェクトが一定よりも少ない場合だけで、オブジェクトの数が多くなるとページフリップの方が却って遅くなってしまいます。これは、一つのオブジェクトにつき、プレーン毎に計 4 回、VRAM への書き込みアクセスが発生するので、あまりに多くなってしまうとオーバーヘッドによってメモリーバスがビジーになったのが原因でしょう。そんなわけで、Brackeen さんはダブルバッファを使うかページフリップを使うかは、プログラムで描画するオブジェクトの数などを考慮して考えた方がいいと断わっています。

というわけで、僕は非連鎖モードについては詳しくサンプルのソースまで検証するのは割愛しようと思います。ただ、0x13 以外の非連鎖方式の画面モードを使いたい場合には、速度のためではなく、結局、プレーンにまたがった VRAM の書き込みが必要となるので、その場合は、改めて勉強する必要が生じると思います。

以上で Devid Brackeen さんのテキスト「256-Color VGA Programming in C」を順に追った、DOS のグラフィックスプログラミングの勉強は一通り終りました。

DOS ゲームの制作のために、グラフィックス関係については、これくらい勉強しておけば、準備としては十分じゃないかと思います。あと他に勉強する必要があるとしたら、サウンド(SB16 互換)でしょうかね。とはいえ最初はサウンドは beep だけにしてあまりこだわらないことにしたいと思います。グラフィックス関係について慣れないうちから、サウンドの方の勉強も始めてしまったら、グラフィックスのことを忘れてしまいそうです。

サウンド(Beep 音)

サウンドは思っていたほど難しくありませんでした。とはいっても、Beep 音ですけどね。

基本的に、Beep 音を鳴らすためにすることは 2 つだけです。

  1. PIT を操作して、音の周波数をセットする
  2. 音を意図する時間の長さで鳴らす

PIT というのは、タイマー IC のようですが、この IC には PC スピーカーに送られるパルスの周波数を制御する機能も含まれています。そのための値をセットすることが一つ目。そしてもう一つが、PC スピーカーの音を ON/OFF することだけです。ON/OFF 自体は PC スピーカー用のポート 0x61 に一定の値を書き込むことでできます。この ON/OFF するタイミングは、単にプログラム側の問題です。ON してから sleep(3) して OFF するようにすれば、3 秒間、その音が鳴り続けることになります。

サンプルコードは beep.c として置いておきます。もっと詳しいことが気になる場合は、PC-GPE の speaker.txt や pit.txt、他では Making Music using the PC Speaker などを参照してください。


《以上》