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で表現できそうだったので試してみた。
画像でのディザリングは、画像を少ない色数で表示する際に、 異なる色のドットを混ぜたり配置したりすることで中間色を表現する手法を指す。
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を切り捨てただけでは色数が少なくなり明度も少し落ちる。
画像は今回のサンプルとして使用するスクウェアの『バハムートラグーン』。
どろどろの恋愛劇(?)が爽やかなサウンドと美麗なピクセルアートで繰り広げられるゲームだ。
5bitのうち最下位1bitを切り捨てたもの。 中間値が表現されないため明度も落ちている。
|
0bxxxx1 のように最下位bitが1の場合、0bxxxx を切り捨て値、0bxxxx + 0b0001 を繰り上げ値とする。
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;
ドット0を切り捨て値、ドット1を繰り上げ値とする。 ^はNOT演算子。
reg dot;
// ドットで切り替え
instruct dsync par{
dot := ^dot;
}
if(dot) col_out = upper;
else col_out = lower;
縦縞模様が目立ってしまう。
|
ライン0を切り捨て値、ライン1を繰り上げ値とする。
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演算子。
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)として知られている。
reg frame;
// フレームで切り替え
instruct vsync par{
frame := ^frame;
}
if(frame) col_out = upper;
else col_out = lower;
画面全体にフリッカーが見られるためこれはダメ。
↓↑
|
ドット0とドット1をフレーム毎にフリップ。
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;
一見よさそうだが、横スクロール時に縦縞が見えてしまう。
↓↑
|
ライン0とライン1をフレーム毎にフリップする。
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;
これも一見良さそうだが、縦スクロール時に横縞が見えてしまう。
↓↑
|
これが一番いいと思います。
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ドットモードで描画したりする場合、見え方が変わってしまう可能性はある。
PlayStation on FPGA の場合はRGB888だが、たまにDE0-CVでテストすることもあるのでこれもディザリングを適用したい。 まずはさくっとコンパイルできる ポリゴンデモ feat. DE0-CV で試してみる。
8bitのうち上位4bitを使用したもの。ディザリングなし。
8bitのうち上位5bitを使用し、最下位1bitで前述のRGB555ディザリングを行ったもの。
まだディザリングのbit数増やせそう。
8bitのうち上位6bitを使用し、そのうち下位2bitをパターンディザリングに使用する。 パターンのドット位置はフレームによってフリップさせる。
// フレームで切り替え
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;
8bitのうち上位7bitを使用し、下位3bitをFRCパターンディザリングに使用する。 FRCドットは2フレーム内で1フレームを繰り上げ値とする。
// フレームで切り替え
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;
下位4bitをFRCパターンディザリングに使用する。 FRCドットは4フレーム内で1/4、2/4、3/4フレームを繰り上げ値とする。
// フレームで切り替え
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;
フリッカー(ちらつき)の感想がざっくりとしているため定量的な評価として、
フリッカー指数(FI) = (最大輝度 − 最小輝度) / (最大輝度 + 最小輝度)
を使用する。
ディザリング実装 | フリッカー指数(FI) |
---|---|
Dithering_5to4 | 0.000540 |
Dithering_6to4 | 0.006714 |
Dithering_7to4 | 0.798828 |
Dithering_8to4 | 1.185335 |
VGAをモニタに出力してみると色が変わるようなノイズが乗ることがあった。
ノイズの模様が出る位置の周期性が見られるので、色信号のスキューが原因ではないかと考えられる。
そこでFPGAのピンアサイン設定で出力ピンのレジスタを使用する設定により多少改善した。
他のモニタでは強力な自動調整機能でノイズは抑えられたため、モニタの性能によるところもあるのかもしれない。
閑話休題。
初代PlayStationのホーム画面は実機では640x480だが、 FPGA実装の制限で画面サイズは320x240で行っている。 このためSNES実装同様に640x480に拡大してVGA出力している。
8bitのうち下位4bitを切り捨て、上位4bitを表示したもの。
グラデーションの色数が少ない。
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]に接続)すると良い感じに明度を保つことができる。
ちなみにモニタの写真を撮るにあたり、 モアレが出ないようかつまっすぐに回転した時に歪まないようにカメラを斜めに構えて撮るのが大変だった。