はじめに
クリスマスアドベント企画として、Hardware Description Language (HDL) アドベントカレンダー24日目は、Bluespec SystemVerilog (BSV) でキラキラ聖夜を作ります。
グラフィックコントローラやVRAMを使用するので、インベーダーゲーム のリソースを再利用します。解像度は 256×256、色は原色 8 色と、だいたい 45 年前くらいのレトロ感です。
それでも、結果としては 8bit(どこが?)マシンとは思えないくらい立体感のある映像になりました。
……単にもとの画像に立体感があるだけなのですが。
機材
- Arty A7 ボード
- PMOD VGA ボード
- VGA/HDMI 変換
画像のロード
デコーダ関数
まず画像のロード関数です。元画像はこのサイトにあった解像度の高いロゴ画像を256x256に縮め、さらに色数を4色に落としました。ただ単純にパターンROMに画像を置いてVRAMに転送するだけではあまり面白くないので、RLE (run length encoding)を行いました。元画像は Python でエンコードしましたが、単純なベタ塗りパターンだったため、約 1/3 までデータ量を圧縮することができました。
以下にデコーダ部のBSVコードを示します。このデコーダはROMから$color, runlen_\text{high}, runlen_\text{low})$の4bit要素の三つ組みを読み取り、2次元座標$(x, y)$に書いていくものです。
// PROM アドレス(1 nibble 単位)
Reg#(PAddr_t) rleAddr <- mkReg(0);
// 展開先の相対座標 (0..255, 0..255)
Reg#(U8) rleX <- mkReg(0);
Reg#(U8) rleY <- mkReg(0);
// 残りピクセル数 256 * 256
Reg#(UInt#(16)) pixelsLeft <- mkReg(0);
// 現在のランの残り長さ (1..256)
Reg#(UInt#(9)) runLen <- mkReg(0);
// 色と長さ nibble
Reg#(Pattern_t) rleColor <- mkReg(0);
Reg#(Pattern_t) rleLenHiNib <- mkReg(0);
Reg#(Pattern_t) rleLenLoNib <- mkReg(0);
// PROM から nibble(Pattern_t) を1つ読み取り、dst に入れ、rleAddr を 1 進める
function Stmt rleNextNibble(Reg#(Pattern_t) dst);
return (seq
action
// PROM アドレスをセット
p_addr <= rleAddr;
endaction
// read timing 調整が必要ならここに noAction を挟む
noAction;
action
// nibble を取り込み
dst <= romdata; // romdata : Pattern_t
rleAddr <= rleAddr + 1; // 次の nibble へ
endaction
endseq);
endfunction
// PROM 上で RLE が始まるニブルアドレス(行256の先頭なら 128*256)
`define RLE_START_ADDR 128*256
`define VRAM_WIDTH 256
`define VRAM_HEIGHT 256
function Stmt rleDecode_org();
return (seq
//------------------------------------------------
// 初期化
//------------------------------------------------
action
// RLE データは PROM の 256 行目から始まる前提
rleAddr <= fromInteger(`RLE_START_ADDR);
// 出力先相対座標
rleX <= 0;
rleY <= 0;
pixelsLeft <= fromInteger(`VRAM_WIDTH * `VRAM_HEIGHT -1);
runLen <= 0;
endaction
//------------------------------------------------
// 全ピクセルを描き終えるまでループ
//------------------------------------------------
while (pixelsLeft != 0) seq
// --- 新しいランを読み込む必要があるなら ---
if (runLen == 0) seq
// color
rleNextNibble(rleColor);
// len_hi
rleNextNibble(rleLenHiNib);
// len_lo
rleNextNibble(rleLenLoNib);
action
UInt#(4) hi = unpack(rleLenHiNib);
UInt#(4) lo = unpack(rleLenLoNib);
// ラン長 L = 16*hi + lo + 1
runLen <= extend(unpack({pack(hi), pack(lo)})) + 1;
endaction
endseq
// --- このランから 1 ピクセル描画 ---
setDot(rleX, rleY, rleColor);
action
pixelsLeft <= pixelsLeft - 1;
runLen <= runLen - 1;
// 256 幅で折り返し
if (rleX == 8'd255) begin
rleX <= 0;
rleY <= rleY + 1;
end
else begin
rleX <= rleX + 1;
end
endaction
endseq // while (pixelsLeft != 0)
endseq);
endfunction
FSM rle_fsm <- mkFSM(rleDecode_org());
// rleDecodeを起動するラッパ
function Stmt rleDecode();
return (seq
`RUN_FSM(rle_fsm)
endseq);
endfunction
上記最後の rleDecode 関数は、本体 rleDecode_org を起動するだけの薄いラッパです。これは HDL アドベントカレンダー 12 月 5 日の「巨大 FSM のダイエット計画」で紹介した方法で、重いシーケンスを小 FSM に切り出し、メインからはラッパを呼び出して終了を待つだけにするものです。これにより、メインシーケンス側の状態爆発を抑えられます。
さらに私は BSV の rule が苦手なので、HDL アドベントカレンダー 12 月 17 日の「Rule を用いない BSV の設計手法」で解説した手法を用いています。従って C のように while ループが書いてあるだけなので、読みやすいと思います。
キラキラ聖夜
星座標
星の座標は $(x, y)$ではなく$(x, y, P, \phi)$の4つ組で表します。周期 $P$、位相$\phi$を各星に持たせることで、ランダムに点滅しているように見せます。以下はそのテーブルの一部です。
// loaded 76 stars from stars_xy.csv
// ps(x, y, P, phi) 形式
ps( 0, 0, 64, 19),
ps(204, 1, 16, 7),
ps( 63, 2, 16, 11),
:
ps( 33, 251, 32, 8),
ps(165, 254, 8, 0),
ps(232, 254, 64, 33)
このテーブルの作成にあたっては、原画像の白点を星と見立て、その抽出から座標テーブル生成までを行う Python スクリプトを ChatGPT に作ってもらいました。
アルゴリズム
ランダムに点滅するアルゴリズムは
案3:座標から「周期と位相」を決めるだけの簡単版
擬似乱数すら使わず、各星に違う周期と位相を与えるだけでも、それなりに「ランダムっぽく」見えます。
フレームカウンタ$t$を用意。
星の座標$(x,y)$から、その星固有の周期$P$と位相$\phi$を決める。
例:
$$
P = 8 + ((x + 2y) \bmod 5) \quad (8 \sim 12; \text{フレーム周期})
$$
$$
\phi = (3x + y) \bmod P
$$星は次の条件で ON にする。
$$
\text{star_on} = 1 \iff ((t + \phi) \bmod P) < \frac{P}{2}
$$
- つまり各星は周期$P$で点滅し、そのうち半分だけ点灯。
- 星ごとに$P$と$\phi$が違うため、全体としてはかなりバラバラに光って見える。
ハード実装としては
- $P$を事前に ROM に入れておくか、座標から組み合わせ回路で計算、
- Pを2の冪にすれば、$\bmod P$の演算も不要
ご覧のとおり、このアルゴリズムも ChatGPT に教えてもらったものです。
聖夜表示関数
以下は、星の点滅を 1 フレーム分更新する BSV コードです。
// pack ∘ st のラッパ (packed star)
function Bit#(SizeOf#(StarTwinkle)) ps(U8 x, U8 y, U8 p, U8 phi);
return pack(st(x, y, p, phi));
endfunction
// 星テーブル (packed ビット列)
Bit#(SizeOf#(Vector#(NumStars, StarTwinkle))) starTwinkleBits = {
// ps(x, y, P, phi) 形式
`include "stars_xy.vh"
};
// Vector#(NUM_STARS, StarTwinkle) に変換
Vector#(NumStars, StarTwinkle) starTwinkleTable =
reverse(unpack(starTwinkleBits));
// 1フレーム分、星の点滅を更新する Stmt
// count : グローバルフレームカウンタ (60Hzで+1される想定)
function Stmt stepStarField();
let s = starTwinkleTable[i];
let phaseIdx = (truncate(counter) + s.phase) % s.period;
let on = (phaseIdx < (s.period >> 1));
return (seq
for (i <= 0; i < fromInteger(valueOf(NumStars)); i <= i + 1) seq
if (on)
setDot(s.x, s.y, 7);
else
setDot(s.x, s.y, 0);
endseq
endseq);
endfunction
4 つ組を配列にする部分は少し凝った書き方をしています。配列を並べて初期化したかったので、いったんビット列に pack しています。実際に使うときは、そのビット列に対してあらかじめ unpack と reverse を行ったテーブルを普通に引くだけです。
完成
お待ちかねの完成結果をご紹介します。GIF だと圧縮の都合で少し粗く見えますが、実機ではもう少し滑らかにキラキラして見えます。
さいごに
今回は ChatGPT 5.1 に各種サポートプログラムを Python で作成してもらったほか、BSV についても 9 割方は Chat によるもので、私はほぼデバッグ係になっていました。
ChatGPT 5.1 は、慣れていない BSV では時々文法誤りのコードを吐くものの、Python については一度もバグを入れることなく動くコードを出してくれました。Python の方が得意なのは、世の中にあるコードベースの量を考えれば当然かもしれません。
これほど Python が得意なら、今後は人間が Python を書く機会はどんどん減り、Python は機械語のような位置付けになるかもしれません。昔は機械語のバイナリを覚えたものですが(C3,00,80,...)、それと同じような存在になりそうです。
幸いChatGPTは、BSVはそこまで得意ではないので、人間の書く楽しみがまだ辛うじて残されているのかもしれません。
