SNES on FPGA

🎄 Hardware Description Language Advent Calendar 2024 23日目
@pgate1

VGA from FPGA でFRCディザリングを試す

最終更新日 2024年12月23日 投稿日 2024年12月23日

はじめに

2014年にTerasic社より発売された DE0-CV は、 SNES on FPGA を実装するのにちょうど良い感じのFPGAボードだが、 安価で古いということもあって機能的に惜しいところもある。

SNES(スーファミ)の描画プロセッサ(PPU:Picture Processing Unit)からの出力は RGB555(RGB各5bit)に対して、 DE0-CVのVGA出力はRGB444であり、 これまで最下位1bitを切り捨てていたため スーファミの高度に発達したピクセルアートの再現が不完全だった。 他のDE0-CV向けスーファミ実装として えだるふ氏のepSFC もあるが、こちらも最下位1bitを切り捨てているようだ。

この度、ディザリングを使えばRGB555をRGB444で表現できそうだったので試してみた。

VGA出力とディザリングの方針

画像でのディザリングは、画像を少ない色数で表示する際に、 異なる色のドットを混ぜたり配置したりすることで中間色を表現する手法を指す。

参考: ディザ - Wikipedia

SNESのPPU出力は通常 256x240 ドットで、 これをVGAで表示するために 512x480 ピクセルに拡大している(実際は左右に余白を入れて横640)。 これには256ドットのラインダブルバッファを使用し、 PPUが1ライン描画中に、VGAを2ライン出力する。

つまり1ピクセルが 2x2 ドットとなっていて、 ピクセルの色が中間色となるように 2x2 の4ドットのうち2ドットに+1した値を適用する。

RGB555からRGB444にするということは最下位bitの扱いをどうするかということで、 最下位bitが1(中間色)ならディザリングを行い、 0(中間色でない)ならディザリングは行わないこととする。 つまり周辺ピクセルへの誤差拡散は行わない。 ラインバッファを使用している都合上においても、空間的誤差拡散は適用できない。

DE0-CVのVGA出力は4bit分の重み抵抗型DACによってアナログ信号が出力される。 DE0-CV User Manual より引用。

最下位bitを切り捨てただけでは色数が少なくなり明度も少し落ちる。

画像は今回のサンプルとして使用するスクウェアの『バハムートラグーン』。 どろどろの恋愛劇(?)が爽やかなサウンドと美麗なピクセルアートで繰り広げられるゲームだ。

SNES on FPGA feat. DE0-CV に適用してみる

ディザリングなし

5bitのうち最下位1bitを切り捨てたもの。 中間値が表現されないため明度も落ちている。

ディザリングのために

0bxxxx1 のように最下位bitが1の場合、0bxxxx を切り捨て値、0bxxxx + 0b0001 を繰り上げ値とする。

Dithering_5to4.sflp

	input col_in<5>;
	output col_out<4>;

	// 最下位1bit切り捨てたものをlowerとする
	sel lower<4>;
	lower = col_in<4:1>;

	// +1したものをupperとする
	sel col_inc<5>, upper<4>;
	col_inc = (0b0||lower) + 0b0_0001;
	if(col_inc<4>) upper = 0b1111;
	else upper = col_inc<3:0>;

	if(upper条件) col_out = upper;
	else col_out = lower;

ドット毎に+1

ドット0を切り捨て値、ドット1を繰り上げ値とする。 ^はNOT演算子。

Dithering_5to4.sflp

	reg dot;

	// ドットで切り替え
	instruct dsync par{
		dot := ^dot;
	}

	if(dot) col_out = upper;
	else col_out = lower;

縦縞模様が目立ってしまう。

ライン毎に+1

ライン0を切り捨て値、ライン1を繰り上げ値とする。

Dithering_5to4.sflp

	reg line;

	// ラインで切り替え
	instruct hsync par{
		line := ^line;
	}

	if(line) col_out = upper;
	else col_out = lower;

これは横縞模様が目立ってしまう。

ドット毎とライン毎の組み合わせ

ライン0かつドット0とライン1かつドット1を切り捨て値とし、 ライン0かつドット1とライン1かつドット0を繰り上げ値とする。 @はXOR演算子。

Dithering_5to4.sflp

	reg line, dot;

	// ラインで切り替え
	instruct hsync par{
		line := ^line;
		dot := 0b0;
	}
	// ドットで切り替え
	instruct dsync if(^hsync){
		dot := ^dot;
	}

	if(line @ dot) col_out = upper;
	else col_out = lower;

縦縞や横縞は目立たないが、ピクセルが斜めに見えてしまう。

フレーム毎にフリップしてみる

このままではピクセルが細かいドット描画となるだけで、ピクセル描画の正確性は損なわれる。 そこで、フレーム毎に+1したドットの位置を変えることで、残像効果によりピクセルの中間色を表現する。 これは FRC(Frame Rate Control)として知られている。

参考: Frame rate control - Wikipedia

2x2をフレーム毎にフリップ

Dithering_5to4.sflp

	reg frame;

	// フレームで切り替え
	instruct vsync par{
		frame := ^frame;
	}

	if(frame) col_out = upper;
	else col_out = lower;

画面全体にフリッカーが見られるためこれはダメ。
↓↑

ドット毎に+1をフリップ

ドット0とドット1をフレーム毎にフリップ。

Dithering_5to4.sflp

	reg frame, dot;

	// フレームで切り替え
	instruct vsync par{
		frame := ^frame;
		dot := 0b0;
	}
	// ドットで切り替え
	instruct dsync if(^vsync){
		dot := ^dot;
	}

	if(frame @ dot) col_out = upper;
	else col_out = lower;

一見よさそうだが、横スクロール時に縦縞が見えてしまう。
↓↑

ライン毎に+1をフリップ

ライン0とライン1をフレーム毎にフリップする。

Dithering_5to4.sflp

	reg frame, line;

	// フレームで切り替え
	instruct vsync par{
		frame := ^frame;
		line := 0b0;
	}
	// ラインで切り替え
	instruct hsync if(^vsync){
		line := ^line;
	}

	if(frame @ line) col_out = upper;
	else col_out = lower;

これも一見良さそうだが、縦スクロール時に横縞が見えてしまう。
↓↑

ライン毎とドット毎の組み合わせをフリップ

これが一番いいと思います。

Dithering_5to4.sflp

	reg frame, line, dot;

	// フレームで切り替え
	instruct vsync par{
		frame := ^frame;
		line := 0b0;
	}
	// ラインで切り替え
	instruct hsync if(^vsync){
		line := ^line;
		dot := 0b0;
	}
	// ドットで切り替え
	instruct dsync if(^vsync & ^hsync){
		dot := ^dot;
	}

	if(frame @ line @ dot) col_out = upper;
	else col_out = lower;

ピクセルが斜めに見えてしまうものが解消され、 フリッカーも見られず、スクロールしても縞模様は見られない。
↓↑
結果、最初のこれが

こうなる。

キレイなった
減色されて潰れていたピクセルの中間値を表現でき、 明度も改善された。 フリッカーは一見して見られない程度には抑えられている (モニタのフリッカー抑制機能が働いている?)。 他のモニタでもきれいに表示できた。

ただ、考えられる欠点として、 SNESの画面効果で明滅するタイミングがフリップタイミングと一致してしまったり、 PPU横512ドットモードで描画したりする場合、見え方が変わってしまう可能性はある。

ディザリングに使用するbit幅を増やしてみよう

PlayStation on FPGA の場合はRGB888だが、たまにDE0-CVでテストすることもあるのでこれもディザリングを適用したい。 まずはさくっとコンパイルできる ポリゴンデモ feat. DE0-CV で試してみる。

RGB444

8bitのうち上位4bitを使用したもの。ディザリングなし。

Dithering_5to4

8bitのうち上位5bitを使用し、最下位1bitで前述のRGB555ディザリングを行ったもの。
まだディザリングのbit数増やせそう。

Dithering_6to4

8bitのうち上位6bitを使用し、そのうち下位2bitをパターンディザリングに使用する。 パターンのドット位置はフレームによってフリップさせる。

Dithering_6to4.sflp

	// フレームで切り替え
	reg frame;
	instruct vsync par{
		frame := ^frame;
	}

	// 下位2bitによりパターンを選択
	sel pat<4>;
	switch(col_in<1:0>){
		case 0: pat = 0b0000;
		case 1: pat = 0b0001;
		case 2: pat = 0b0011;
		case 3: pat = 0b0111;
	}

	// フレームでドット位置入れ替え
	sel patx<4>;
	if(col_in<1:0>==2){
		switch(frame){
			case 0b0: patx = pat<1> || pat<3> || pat<2> || pat<0>;
			case 0b1: patx = pat<3> || pat<1> || pat<0> || pat<2>;
		}
	}
	else{
		switch(frame){
			case 0b0: patx = pat<3> || pat<1> || pat<2> || pat<0>;
			case 0b1: patx = pat<0> || pat<2> || pat<1> || pat<3>;
		}
	}

	// 2x2から選択
	sel p;
	p = patx<line||dot>;

	if(p) col_out = upper;
	else col_out = lower;



まだフリッカーは見られない。 まだディザリングのbit数増やせそう。

Dithering_7to4

8bitのうち上位7bitを使用し、下位3bitをFRCパターンディザリングに使用する。 FRCドットは2フレーム内で1フレームを繰り上げ値とする。

Dithering_7to4.sflp

	// フレームで切り替え
	reg frame;
	instruct vsync par{
		frame := ^frame;
	}

	// ドットのFRC
	sel frc0, frc1, frc2;
	frc0 = 0b0;
	frc1 = frame;
	frc2 = 0b1;

	// 下位3bitによりパターンを選択
	sel pat<4>;
	switch(col_in<2:0>){
		case 0: pat = frc0 || frc0 || frc0 || frc0;
		case 1: pat = frc0 || frc0 || frc0 || frc1;
		case 2: pat = frc0 || frc0 || frc0 || frc2;
		case 3: pat = frc0 || frc0 || frc1 || frc2;
		case 4: pat = frc0 || frc0 || frc2 || frc2;
		case 5: pat = frc0 || frc1 || frc2 || frc2;
		case 6: pat = frc0 || frc2 || frc2 || frc2;
		case 7: pat = frc1 || frc2 || frc2 || frc2;
	}

	// フレームでドット位置入れ替え
	sel patx<4>;
	if((col_in<2:0>==3) | (col_in<2:0>==4) | (col_in<2:0>==5)){
		switch(frame){
			case 0b0: patx = pat<1> || pat<3> || pat<2> || pat<0>;
			case 0b1: patx = pat<3> || pat<1> || pat<0> || pat<2>;
		}
	}
	else{
		switch(frame){
			case 0b0: patx = pat<3> || pat<1> || pat<2> || pat<0>;
			case 0b1: patx = pat<0> || pat<2> || pat<1> || pat<3>;
		}
	}

	// 2x2から選択
	sel p;
	p = patx<line||dot>;

	if(p) col_out = upper;
	else col_out = lower;



だいぶディザリング効果は出ているが、少々フリッカーが見られる。

Dithering_8to4

下位4bitをFRCパターンディザリングに使用する。 FRCドットは4フレーム内で1/4、2/4、3/4フレームを繰り上げ値とする。

Dithering_8to4.sflp

	// フレームで切り替え
	reg frame<2>;
	instruct vsync par{
		frame++;
	}

	// ドットのFRC
	sel frc0, frc1, frc2, frc3, frc4;
	frc0 = 0b0;
	frc1 = (frame==0);
	frc2 = (frame==0) | (frame==2);
	frc3 = (frame==0) | (frame==1) | (frame==2);
	frc4 = 0b1;

	// 下位4bitによりパターンを選択
	sel pat<4>;
	switch(col_in<3:0>){
		case 0x0: pat = frc0 || frc0 || frc0 || frc0;
		case 0x1: pat = frc0 || frc0 || frc0 || frc1;
		case 0x2: pat = frc0 || frc0 || frc0 || frc2;
		case 0x3: pat = frc0 || frc0 || frc0 || frc3;
		case 0x4: pat = frc0 || frc0 || frc0 || frc4;
		case 0x5: pat = frc0 || frc0 || frc1 || frc4;
		case 0x6: pat = frc0 || frc0 || frc2 || frc4;
		case 0x7: pat = frc0 || frc0 || frc3 || frc4;
		case 0x8: pat = frc0 || frc0 || frc4 || frc4;
		case 0x9: pat = frc0 || frc1 || frc4 || frc4;
		case 0xA: pat = frc0 || frc2 || frc4 || frc4;
		case 0xB: pat = frc0 || frc3 || frc4 || frc4;
		case 0xC: pat = frc0 || frc4 || frc4 || frc4;
		case 0xD: pat = frc1 || frc4 || frc4 || frc4;
		case 0xE: pat = frc2 || frc4 || frc4 || frc4;
		case 0xF: pat = frc3 || frc4 || frc4 || frc4;
	}

	// フレームでドット位置入れ替え
	sel patx<4>;
	if((col_in<3:0>==0x5) | (col_in<3:0>==0x6) | (col_in<3:0>==0x7) | (col_in<3:0>==0x8)
	 | (col_in<3:0>==0x9) | (col_in<3:0>==0xA) | (col_in<3:0>==0xB)){
		switch(frame<0>){
			case 0b0: patx = pat<1> || pat<3> || pat<2> || pat<0>;
			case 0b1: patx = pat<3> || pat<1> || pat<0> || pat<2>;
		}
	}
	else{
		switch(frame<0>){
			case 0b0: patx = pat<3> || pat<1> || pat<2> || pat<0>;
			case 0b1: patx = pat<0> || pat<2> || pat<1> || pat<3>;
		}
	}

	// 2x2から選択
	sel p;
	p = patx<line||dot>;

	if(p) col_out = upper;
	else col_out = lower;



ディザリング効果は出ているがFRCレートも落ちるためフリッカーが見られる。 FRCドットが4パターンとなるためドット位置入れ替えにもう少し工夫が必要。

フリッカーの定量的評価

フリッカー(ちらつき)の感想がざっくりとしているため定量的な評価として、
 フリッカー指数(FI) = (最大輝度 − 最小輝度) / (最大輝度 + 最小輝度)
を使用する。

参考: フリッカー - Wikipedia
フレームの平均輝度を隣接するフレームで比較しFIを算出する。 シミュレーションとFI算出にはverilatorを使用した。
ディザリング実装フリッカー指数(FI)
Dithering_5to40.000540
Dithering_6to40.006714
Dithering_7to40.798828
Dithering_8to41.185335
蛍光管のフリッカー指数は0.07らしい。 Dithering_7to4とDithering_8to4のフリッカーが大きいことが分かる。 なんで1超えてんねん計算式間違えたか?

ところでノイズ?が出る

VGAをモニタに出力してみると色が変わるようなノイズが乗ることがあった。

ノイズの模様が出る位置の周期性が見られるので、色信号のスキューが原因ではないかと考えられる。 そこでFPGAのピンアサイン設定で出力ピンのレジスタを使用する設定により多少改善した。 他のモニタでは強力な自動調整機能でノイズは抑えられたため、モニタの性能によるところもあるのかもしれない。 閑話休題。

PlayStation on FPGA feat. DE0-CV に適用してみる

初代PlayStationのホーム画面は実機では640x480だが、 FPGA実装の制限で画面サイズは320x240で行っている。 このためSNES実装同様に640x480に拡大してVGA出力している。

ディザリングなし

8bitのうち下位4bitを切り捨て、上位4bitを表示したもの。 グラデーションの色数が少ない。

Dithering_8to4

8bitのうち下位4bitで上述のFRCディザリングを適用したもの。

視覚的にはグラデーションがきれいになる効果は見られるが、 やはり明るい部分で多少のフリッカーがある。 Dithering_7to4くらいがいいかもしれない。 まぁこれは試してみたかっただけ。

おわりに

FRCディザリングによって、SNES on FPGA feat. DE0-CV のVGA出力が綺麗になった。 VGAはRGB444で事足りたので、Terasicは初めからディザリングによって表示の改善ができることを想定していた…?

RGB888 to RGB444のディザリングについては、8to7 → 7to6 → 6to5 → 5to4 というように、 カスケード的に実装する方法もあるかもしれない。

最近のFPGAボードならRGB888のDVI出力が容易にできるので、 その場合はRGB555を正規化(5bitのうち[4:2]を8bitの[2:0]に接続)すると良い感じに明度を保つことができる。

ちなみにモニタの写真を撮るにあたり、 モアレが出ないようかつまっすぐに回転した時に歪まないようにカメラを斜めに構えて撮るのが大変だった。

 成果物(GitHub)


©2024 pgate1