SL9821

はじめに

ダウンロード

使い方

FAQ

実行画面

技術的なはなし

プログラム構成

CPU

テキスト

グラフィック

PEGC

サウンド

グラフィック機能は本エミュレータを作る最も大きな動機の一つで、機能の再現には最も力を入れている機能の一つです。

表示の仕組み

以下にグラフィック表示の機能ブロックの概略図を示します。

見た目はテキストの機能ブロックより若干シンプルですが、実際のソフトウェア処理量はグラフィックのほうが遙かに大きいです。
メモリアクセスに対しては関数で受けますが、グラフィックは単純なV-RAMアクセスの他、GRCG-TDW/TCRモード、GRCG-RMWモード、EGCモード、256色パックトピクセルモード、256色プレーンモードとそれぞれV-RAMアクセス時の対応が全く異なるため、モード切替時に受け口の処理関数が切り替わるようにしています。
各モードの実現方法の概要については後述するとして、まずはグラフィック機能で扱うデータの構造について記述します。

●関連ソース
videos.c
videogdcs.c
video256.c
videoEgc.c
video_win.cpp(video_osx.m)
graphShader.fx
arch9821.c

グラフィックV-RAMのデータ管理

16色モードのグラフィックV-RAMのデータは実機とは異なるデータ構造で管理しています。グラフィックV-RAMは実機ではプレーン毎にデータが管理されていますが、エミュレータ内部では、8ピクセル1単位で各プレーンをひとまとめにした32ビットのデータとして以下のように扱います。なお、リードライト共にこのデータに基づいて対応するため、実機のようにプレーン毎に分割した形でのデータの管理は行っていません。


256色モードについては図は省略しますが、こちらは1バイトに1パレット情報のパックトピクセルモードと同じデータ構造で管理しています(テクスチャバッファ上では、RGBAそれぞれに1ピクセル分のパレット情報が格納されているように見えます)。

Yラインデータ

テキスト表示と同様、グラフィック表示においてもYライン毎に左端のVRAM上の表示位置を管理します。これはGDCのSCROLLコマンド、PITCHコマンド、CSRFORMコマンドによって参照する位置が変わりうることへの対応になります。管理する内容はテキストほど複雑ではないので左端の表示位置のオフセットのみになります。ただ、16色モードのみは後述するラスタパレット処理の実現のためテクスチャバッファへコピーする際にパレット構成番号が付加されます。

パレットデータ

グラフィック機能のカラー性能は8色モード、アナログ16色モード、256色モードで異なりポートで設定される値も各モードで異なりますが、内部では8色モードのみ設定が特殊なため、ポートの読み出し時の応答用に設定されたデータを別途保持しておきますが、すべてのカラーモードでパレット情報を32ビットの表示色として管理します。また16色モードのみの制限はありますがパレット変更時の垂直走査位置を垂直同期からの経過時間から論理的に推測し、パレットカラーの変更を対応するYライン位置から行うラスターパレット処理を実装しています(が、正しく動作しているかの検証は出来ていません)。

テクスチャバッファとレンダリング

テクスチャバッファには、メインメモリからYラインデータ、パレットデータ、グラフィックデータをコピーし、レンダリングに使用します。テクスチャバッファ上のデータは表示色モードによって異なり、表示色モード毎に異なるシェーダのプログラムによってレンダリングが行われます。
まずバーテックスシェーダは、テキスト表示同様画面の4隅を頂点とした矩形を表示領域全体に表示するだけの処理になります。
ピクセルシェーダでは、まず優先順位としてテキスト画面のほうが優先されるため既にテキストで色がつけられたピクセルは処理をスキップします。 次に、描画するピクセル(x,y)に対して、xからは左端からのピクセル数、yからは対応するyラインデータを参照します。yラインデータとx座標から参照するグラフィックデータの位置が特定できます。グラフィックデータの構成は16色以下のモードでは1データに8ピクセル分が格納されていて、256色モードは1データに4ピクセル分格納されているので256色モードとそれ以外では参照方法が異なります。それぞれのモードの特徴としては、
・モノクロモード
モノクロモードの色指定はテキストのアトリビュートで指定するカラーに同期するため、テキスト描画から引き継いだ色情報を使用します。 これを実現するために、テキスト描画の際にはテキスト画面において色をつけるピクセルかつけないピクセルかの判別をアルファ値によって区別するようにし、RGB値に関してはそのまま値として残すようにしています。描画の際には、グラフィック面で色をつける場合にはカラー情報をテキスト面から参照しアルファ値を1.0にし、色をつけない場合にはRGBAすべてを0.0にします。
・8/16色モード
8色モードと16色モードはパレットカラーの制約の他に違いがないため同じプログラムを使用します。x,y座標から特定したグラフィックデータからパレット番号を取得し、パレットテーブルの値をピクセルデータとして設定します。
・256色モード
256色モードも文章で言うと、x,y座標から特定したグラフィックデータからパレット番号を取得し、パレットテーブルの値をピクセルデータとして設定します。と、同じ文言になるのですが、ドット毎のパレット情報の並び方が8/16色モードとは異なるため異なるシェーダプログラムを使用する必要があります。

グラフィックのピクセルシェーダはそれほど大きくないので8/16色モードのプログラムについてここで解説します。
ピクセルシェーダとは簡単に言うと、バーテックスシェーダで決定される描画領域の全ピクセルに対して、引数としてラスタライズ化されたピクセル毎の位置情報が渡されるので戻り値として対応するピクセルの色情報を返す関数になります。
ファイル、graphShader.fxのうち、8/16色モードのピクセルシェーダに関連する部分を取り出すと以下のコードになります。


Texture2D srcTex    : register(t0);     // ソーステクスチャ1(グラフィック)
Texture2D srcTxtTex : register(t1);     // ソーステクスチャ2(描画済みテキスト)
SamplerState samNearest : register(s0); // サンプリング関数で最近傍値(nearest neighbour)を取得するパラメータ
cbuffer CBGrpParam : register(b0) {     // 外部固定値
    float  xscale;                      // テキストの横倍角指定 (1.0(80文字) or 0.5(40文字))
    float  yline;                       // 縦ライン数 (480.0/1024.0(400ライン) or 240.0/1024.0(200ライン))
    float  showtxt;                     // テキスト表示の有無 (1.0(あり) or 0.0(なし))
    float  dummy;
};
struct PS_INPUT {                       // ピクセルシェーダの引数
    float4 Pos : SV_POSITION;           // 未使用
    float2 Tex : TEXCOORD0;             // 描画位置(x,y)
};

float4 PS16(PS_INPUT input) : SV_Target {
    float4 txtcol = srcTxtTex.Sample(samNearest, input.Tex * float2(xscale, 1.0)); // (1)
    float2 pos    = float2(input.Tex.y * yline, 0.5 / 129.0);
    float4 linfo  = floor(srcTex.Sample(samNearest, pos) * 255.0 + 0.25);          // (2)
    float4 vinfo;
    float  offset = floor(input.Tex.x * 640.0) / 8.0;                              // (3)
    float  vadr;
    float4 grpcol;
    float  col;
    linfo[2] = offset;
    vadr = dot(linfo.xyz, float3(1.0 / 1024.0, 256.0 / 1024.0, 1.0 / 1024.0));     // (4)
    vadr = fmod(vadr, 32.0);                                                       // (5)
    offset = exp2(floor(frac(offset) * 8.0)) * 255.0 / 256.0;                      // (6)
    pos = float2(frac(vadr), floor(vadr) / 129.0 + 1.5 / 129.0);                   // (7)
    vinfo = frac((srcTex.Sample(samNearest, pos) + 0.5 / 255.0) * offset);         // (8)
    col = linfo[3] * 16.0 / 1024.0 + 512.5 / 1024.0;
    col += dot(step(0.5, vinfo), float4(2.0 / 1024.0, 4.0 / 1024.0, 1.0 / 1024.0, 8.0 / 1024.0)); // (9)
    grpcol = srcTex.Sample(samNearest, float2(col, 0.5 / 129.0));                  // (10)
    return lerp(grpcol, txtcol, step(0.5, txtcol.a * showtxt));                    // (11)
}
      

前半のCPUから入力されるパラメータやソーステクスチャ類の定義と、ピクセルシェーダに渡されるパラメータの構造体定義になります。
ピクセルシェーダの本体はPS16で、先頭の行で変数txtcolにこれから描画するピクセルのテキスト画面の色を取得しています(1)。
取得の方法としては、テキスト表示イメージのテクスチャsrcTxtTexからSampleメソッドにより、描画座標に対応するテキスト画面の色を取得します。x座標にxscaleを乗算するのは40文字モードのときは参照するピクセルの横のオフセットサイズを1/2にするためです(こうすることでテキスト画面の同じピクセルを2回ずつ参照することになり横に間延びした文字になります。半角→等倍角)。
次にYラインデータを取得します。Yラインデータはグラフィック用テクスチャの最上段の列の左半分が該当領域なので、テクスチャの読み出し座標として、x座標に描画位置のy座標、y座標には最上段の0.5/129.0を作成し(pos)、その座標からYラインデータを取得します(linfo)。値は1.0に正規化された値から0.0~255.0の値へ変換しています(2)。
ここで、129.0はグラフィック用テクスチャの縦のピクセル数で、0.5は最上段のピクセルの中央を指定しています。(1.0や2.0はピクセルの境界に当たるためどちらの値が読めるか不定です)
(3)ではoffsetにx軸の左端からのバイトオフセットを格納します。8.0で割っているのは、8ピクセルは同じオフセットを参照するようにしているためです。
(4)でYラインデータからVRAMアドレスを取得します(vadr)。取得の方法として内積(dot)関数を使用していますが、計算の概要としては以下の図のような形になります。

図の補足ですが、CPUからテクスチャバッファに32ビットの整数で格納したデータはシェーダ側からは0.0~1.0の範囲で正規化された値として読み出されます。また、linfoのzの要素に(3)の値が入っていますがこれは(4)の直前の行、linfo[2] = offsetで代入しています。
計算結果は1024.0が分母に来るような小数の値として求めています。この値はテクスチャバッファの横ピクセル数で、この後グラフィックデータを特定するにあたり整数部分がy座標、小数部分がx座標になるようにしているためです。またグラフィックデータの範囲は32.0未満なので続けてラップアラウンド処理として剰余(fmod)をとっています(5)。余談ですが、バージョン0.2.5.0のバグフィックスで追加したコードがこの(5)になります。
続いて(6)(7)でグラフィックデータを取得しパレット番号を算出するための下準備をします。
まず(6)の式はグラフィックデータの一単位である横8ピクセル内の何ピクセル目なのかを特定するための計算します。まずx座標を表すoffsetの値からfrac関数を用いて小数点以下を抽出します。frac(offset)はデータソースがinput.Tex.xである関係から取り得る値は1.0/8.0刻みで0.5/8.0~7.5/8.0になります。
その値を8.0倍して小数点以下を切り捨てる関数floorを用いて整数にします。この結果1.0刻みで0.0~7.0の範囲の値になります。
次に先ほどの計算結果で2の累乗を算出し、その結果に255.0/256.0を掛けます。最終的にoffsetの値は255.0/256.0~255.0/2.0の範囲の値になります。
続いて(7)の式はテクスチャバッファ内で参照するグラフィックデータの位置を特定します。(4)で触れたようにx座標にはvadrの小数部、y座標はvadrの整数部をテクスチャバッファの縦のピクセル数129.0で割り、グラフィックデータの先頭位置のオフセット(1.5/129.0)を加算します。
(8)でsrcTex.Sampleにより実際にグラフィックデータを取り出します。ここで取得した値に演算誤差の補正値0.5/255.0を加算しoffsetを乗算します。
これはどういう計算をしているかというと、offsetは(6)の計算で例えば8ピクセル内の一番左の場合、255.0/256.0、左から2番目の場合は255.0/128.0、一番右は255.0/2.0というように分母が1/2ずつになる値をとります。このような値を掛けると各ビットの数値は所定のビットより左にセットされている値は整数、所定のビットがセットされている場合は0.5、所定のビットより右にセットされている値は0.5未満(0.25,0.125,...)になります。具体的に8ピクセルのうち一番右だけがセットされている場合はsrcTex.Sampleで取得される値は1.0/255.0になるので、offsetを掛ける前の値としては1.5/255.0になります。ここでoffsetが一番右を示している場合値は255.0/2.0であるため1.5/2.0(0.75)になります。
(8)の目的としては所定のビット位置がセットされているかどうか(つまり、少数点以下の値が0.5以上か、未満か)なので整数部は余計のためfrac関数で整数部分を除去しています。
続く(9)の式でカラーパレットの位置を特定します。まずパレットテーブルのベースになる位置としてテクスチャバッファの最上段の中央からラスタスクロール用のパラメータ分右に移動した位置を指定します。ここがパレット0の位置となり、そこから右15ピクセルまでが16色パレットの情報となります。
パレット番号の算出ですが、まずstep(0.5, vinfo)でvinfoのRGBA各要素について0.5以上(ビットがセット)なら1.0、未満(ビットがクリア)なら0.0に変換します。その上でRGBAに係数としてそれぞれ2.0、4.0、1.0、8.0を乗算して総和(つまり内積)をとります。実際の計算ではテクスチャバッファの位置になるのであらかじめ1024.0で割っています。この値をパレットテーブルのベース位置に加算することで参照するパレット情報の位置(col)が決まります。
読み出し位置が決まったので(10)で表示色をsrcTex.Sampleで取得します。
ここまで指定されたピクセルにグラフィック画面としてどのような色を設定するかの計算をしてきましたが、実際に表示する色としてはテキストのほうが手前に表示されるのでテキストで既に色が設定されている場合はそちらが優先されます。(11)ではleapという関数でこれを実現しています。関数leap(x, y, a)は計算結果として x * (1.0 - a) + y * aを返します。(11)の式ではxにグラフィックのカラー、yにテキストのカラー、aにはテキスト画面の表示ありでテキストの透過情報が0.5以上なら1.0、未満なら0.0になる値が入っています(実際の透過情報は1.0か0.0のみが設定されてくるためstepを使わずに、単純にtxtcol.a * showtxtだけで問題ないはずです)。
以上の計算を画面の全ピクセルに対して行うことで、既にレンダリング済みのテキスト画面とグラフィック画面を合成した表示イメージを持つテクスチャが作成されます。

EGC

EGCモードが指定されると、どのように描画するかという方法に関してはポート04Axhで設定された内容に基づき、実際の描画はVRAMへのアクセスによって行われます。
EGCの機能の詳細については非公開とはいえ様々な書籍で解説があるので機能についてはそれらの書籍を参照してもらうこととして、ここではプログラム中での実現方法について解説します。なお、ここで使用する用語は私がエミュレータを作成する上で参考にさせて頂いた、アスキー出版局の「PC-9801スーパーテクニック」に従っています。
EGCモード時のVRAMアクセスに対する処理関数は細かく細分化してあり、EGCのモードや処理している状態に応じて動的に変化します。モードによる違いとしては以下の要素があります。
リード
・コンペアリードを行うか行わないか(ポート04A4hのbit13)
・リードした内容をシフタに格納するかどうか(ポート04A4hのbit12, 11, 10)
・リード時にパターンデータを更新するかどうか(ポート04A4hのbit9, 8)
ライト
・ライト処理をCPUデータ、ROP(ラスタオペレーション)、フォアグラウンドカラー、バックグラウンドカラーのいずれで行うか(ポート04A2hのbit14, 13、ポート04A4hのbit12, 11)
・ライト処理をROPで行う場合、シフタのソースをVRAMにするかCPUにするか(ポート04A4hのbit10)
このほかROPで行う場合の演算の組み合わせや、コンペアリードやシフタとのデータの入出力がある場合のデータの処理方向(ポート04AChのbit12)によって内部で呼ばれる関数を切り替えます。 状態による違いとしては、モード切替後の最初の読み出しか、連続した読み出しの2回目以降かにより処理関数を変えています。

具体的な例として、EGCを使用して画面全体を2ドット右シフトさせるケースを想定し、EGCの設定とそのときの内部処理について説明します。
なお、実際にEGCを使用するには事前に以下の手続きが必要です。
・ポート7ChにC0hを設定し、GRCGを使用可能にする
・ポート6Ahに順に07h、05h、06hを設定し、拡張モードをEGCモードにする
処理の終了時には、以下のように元に戻しておく必要があります。
・ポート6Ahに順に07h、04h、06hを設定し、拡張モードをGRCG互換モードにする
・ポート7Chに00hを設定し、GRCGを使用禁止にする
EGCの設定は以下のようになります。
・ポート04A0hにFFF0hを設定、すべてのプレーンへのアクセスを許可
・ポート04A2hに00FFhを設定、フォアグラウンドカラーとバックグラウンドカラーは無効
・ポート04A6hに28F0hを設定、ROPモード、ソースデータはリードしたVRAMデータ、ROPパターンはソースデータのみ
・ポート04A8hにFFFFhを設定、全ビット使用許可(マスクビットなし)
・ポート04AChに0002hを設定、転送方向インクリメンタル、ソースシフト2ビット、ディスティネーションシフト0ビット
・ポート04AEhに637を設定、転送サイズを638ビット
いくつかポイントとしては、
ポート04A6hのROPパターンにはパターンレジスタやディスティネーションデータの内容とは関係なくソースデータをそのまま出力するため11110000bに設定します。
ポート04AChでソースシフトを2ビットと設定しています。これは読み出し時に2ビットシフトした場所から読み出す設定になります。ディスティネーションは0ビットなので、2ビット右にシフトした場所から読み出し、シフトしていない場所へ書き込むため結果的に左シフトになるという仕掛けです。ここで実際のシフト処理に伴うリードライトで気をつけないといけないのは、最初の(ワード)リードでは2ビットシフトして読み出すため14ビットしか読み出せていないため、シフトなしのライト時に必要な16ビットに足りず、リードの後すぐにライトを行っても書き込みが無視されます。これを回避するため、オフセット0のリードを行った後、続けてオフセット2のリードを行いその後にオフセット0へのライトを行います。その後はリードとライトを交互に行いますが、オフセット78をリードした後は今度はライトを2回行います。このときの2回目のライトの時には読み出したデータの残りが14ビットしかないのですが、この時点でポート04AEhで指定した転送サイズの638ビットの残りも14ビットなので、ライトは正しく行われます。
以上が、EGCを制御する側の設定で、このように設定された場合の内部での関数の選択のされ方を説明します(ここからは実際にvideoEgc.cで使用している関数名をそのまま使います。関数の具体的な処理まで含めて確認したい場合はソースコードと突き合わせて読んでください)。

グラフィックV-RAMへアクセスした際に実行する関数は、ポート04A6hへのライトが行われたときに
・リードは、リードしたアドレスのV-RAM情報をシフタに格納する関数、_gv16eReadSftf
・ライトは、ソースデータとしてV-RAMを使用してROPした結果を出力する関数、_gv16eWriteRopVram
が選択されます。(gvSetEgcFunc)

内部のサブルーチン用関数として、ポート04AChへのライトが行われたときに
・初回のリード時のシフタへの格納用関数に、転送方向がインクリメンタルでソースシフトにビットシフトが必要な場合に使用する関数、gv16eAppendVramRfsub
・2回目以降のリード時のシフタへの格納用関数に、転送方向がインクリメンタルの場合に使用する関数、gv16eAppendVramRcsub
・ラスタオペレーションで使用するサブルーチンには、転送方向がインクリメンタルの場合の初回のライト用関数、gv16eWriteRfRopsub
が設定されます(gvEgcResetShifter)。実際のプログラム中では他のサブルーチン用関数にも設定をしていますが、今回の設定例では呼ばれることはありません。

次に、V-RAMアクセス時の流れですが、
・初回のリードでは設定した関数_gv16eReadSftfが呼ばれ、関数内でサブルーチンgv16eAppendVramRfsubが呼ばれます。そして、次回以降のリードでは関数_gv16eReadSftcが呼ばれるようにアクセス関数を更新します。
・2回目以降のリードでは更新された_gv16eReadSftcが呼ばれ、関数内でサブルーチンgv16eAppendVramRcsubが呼ばれます。
サブルーチンgv16eAppendVramRfsub/gv16eAppendVramRcsubは、内部変数として32ビットx4プレーン分持っているシフトバッファへ入力したV-RAMデータを格納します。初回用と2回目以降用では、シフトバッファに新規に書き込むか、既にデータがあることを前提に既にあるデータをシフトし空いた領域に追加で書き込むようにするかの違いがあります。
・次に、初回のライトでは設定した関数_gv16eWriteRopVramが呼ばれ、関数内ではサブルーチンgv16eWriteRfRopsubが呼ばれます。ラスタオペレーションの演算処理はこのサブルーチンで行われます。gv16eWriteRfRopsubではポート04AEhで指定した転送サイズが今回のライト処理で書かれるビット数より大きい場合、次にサブルーチンが呼ばれたときにはgv16eWriteRcRopsubが呼ばれるようにします。
・2回目以降のライトでは初回同様_gv16eWriteRopVramが呼ばれますが、関数内呼ぶサブルーチンは更新されたgv16eWriteRcRopsubを呼ぶようになります。gv16eWriteRcRopsubでは転送サイズがポート04AEhで指定した転送サイズを超えたら次回サブルーチンとして呼ばれる関数をgv16eWriteRfRopsubに戻します。

文章では非常に分かりづらくなってしまいますが、EGCはモードに応じて実際の演算処理だけでなくリードやライト時に行う処理のあり、なしが設定によって処理が細かく異なるほか、転送方向によっても処理を変える必要があったり、シフタのように内部で保持しているデータも初回のリード、ライトと2回目以降では微妙に処理を変える必要があったりするため処理関数を細分化して都度使用する関数を動的に切り替えて呼び出すようにしています。