1. はじめに
Hardware Description Language (HDL) アドベントカレンダー最終日は、昨日の記事の続きとして ChatGPT に「よりそれっぽい星空」を考えてもらいます。
昨日の記事では前景、つまりインベーダ画面そのものにキラキラ星を出していました。ところが、次の写真を見ると、アップライト筐体には宇宙空間と火星のような背景があり、自分の作成したゲームも背景と合成したくなりました。
そこで背景画像を classicgaming.cc の Space Invaders ページ から入手し、適宜リサイズして、ハーフミラー合成を前提に設計を変更しました。オリジナルでは、背景はハーフミラー越しに見える透過画像で、ゲーム画面は下側のブラウン管映像がハーフミラーに反射して見える仕組みです。つまり「背景 + 反射した前景」の合成になっています。
前景のインベーダ画面と背景の宇宙空間は、ピクセル単位で合成します。ただし、背景をそのまま足すだけでは面白くないので、背景側の星の瞬きもグレードアップしてから合成することにします。
入手した背景は解像度が高いのですが、そのまま背景画像 VRAM に格納すると容量が増えます。そこで背景は 400 dot x 295 dot に縮小して保持し、表示時に拡大して使います。背景自体が暗めなので、拡大による粗さはあまり気になりません。
最近は BSV のコード生成をかなり ChatGPT に任せています。これで良いのかという気もしますが、最近では人間の思考はもっと大事なことに使うほうが良いような気がしてきました。
2. 星の瞬き
昨日の記事では星の点滅アルゴリズムは単純に、
$$
(x, y, period, duty)
$$
- $x$: 星の$x$座標
- $y$: 星の$y$座標
- $period$: 星の点滅周期
- $duty$: 星の点滅デューティ
Python 側で乱数を使って各パラメータを決め、比較的単純なロジックで表示していました。
今回、ChatGPT から改善案(後述)が出てきたので(最近は本日の動向として聞きもしないのに勝手に提案してきます)、その案に任せて実装してみます。
2.1 完成コード
インベーダゲームの画像コントローラは GraphicsFSM という名前で設計しており、SVGA のドットクロックで動作します。表示は横 800 dot、縦 600 dot をスキャンします。
そこに、背景の読み出し、前景の読み出し、星の瞬きの 3 つを合成します。以下はメインとなる星の瞬きロジックです。
typedef UInt#(4) U4;
typedef UInt#(6) U6;
typedef UInt#(8) U8;
typedef UInt#(10) U10;
typedef UInt#(16) U16;
typedef struct {
U10 x;
U10 y;
} StarXY deriving (Bits, Eq);
// ★ 星の数:80 個
typedef 80 NumStars;
// ============================================================
// StarField.vh (XY-only, random twinkle, analog, minimal)
//
// - Candidate positions: stars_xy.vh (psxy(x,y) list)
// - Twinkle: slow random + analog (no periodic m/ph/d artifacts)
// - No %, no 32-bit multiply. 16-bit mix + shifts + small mult only
// - Only exported function you should use: starLevel4(...)
// ============================================================
// -------------------- knobs (edit only here) --------------------
// dim stars so foreground stays dominant
Bit#(4) starWhite = 4'hc;
// extra slow-down: 0=fast, 1=2x slower, 2=4x slower
Integer twExtraShift = 1;
// base tick shift (must be >= 4)
// actual shift = (baseTickShift + twExtraShift) + rate(0..7)
Integer baseTickShift = 5;
// clamp threshold to prevent "always on" in dark regions (bigger => fewer stars)
U8 thClamp = 8'd150;
// -------------------- table (x,y only) --------------------
function StarXY stxy(U10 sx, U10 sy);
return StarXY { x:sx, y:sy };
endfunction // stxy
function Bit#(SizeOf#(StarXY)) psxy(U10 sx, U10 sy);
return pack(stxy(sx, sy));
endfunction // psxy
Bit#(SizeOf#(Vector#(NumStars, StarXY))) starBits = {
`include "stars_xy.vh"
};
Vector#(NumStars, StarXY) starTable = reverse(unpack(starBits));
// -------------------- helpers (internal) --------------------
// 16-bit mix (UInt only: avoids Bit/UInt type traps)
function UInt#(16) mix16(UInt#(16) z);
z = z ^ (z << 7);
z = z ^ (z >> 9);
z = z ^ (z << 8);
return z;
endfunction // mix16
function UInt#(16) phaseXY(U10 sx, U10 sy);
Bit#(10) xb = pack(sx);
Bit #(10) yb = pack(sy);
UInt#(16) k0 = unpack({ xb, yb[5:0] });
UInt#(16) k1 = unpack({ yb, xb[5:0] });
return mix16(k0 ^ (k1 << 1) ^ 16'hA361);
endfunction // phaseXY
function U8 rand8(UInt#(16) ph, UInt#(16) tick, UInt#(16) salt);
UInt#(16) k = ph ^ tick ^ salt;
return truncate(mix16(k));
endfunction // rand8
function U8 lerp8_frac4(U8 a, U8 b, UInt#(4) fracU);
Bit#(4) frac4 = pack(fracU);
U8 v = a;
if (b >= a) begin
U8 d = b - a;
if (frac4[3] == 1'b1) v = v + (d >> 1);
if (frac4[2] == 1'b1) v = v + (d >> 2);
if (frac4[1] == 1'b1) v = v + (d >> 3);
if (frac4[0] == 1'b1) v = v + (d >> 4);
end
else begin
U8 d = a - b;
if (frac4[3] == 1'b1) v = v - (d >> 1);
if (frac4[2] == 1'b1) v = v - (d >> 2);
if (frac4[1] == 1'b1) v = v - (d >> 3);
if (frac4[0] == 1'b1) v = v - (d >> 4);
end
return v;
endfunction // lerp8_frac4
function UInt#(4) lum4(Bit#(4) r4, Bit#(4) g4, Bit#(4) b4);
UInt#(6) r_u = unpack(zeroExtend(r4));
UInt#(6) g_u = unpack(zeroExtend(g4));
UInt#(6) b_u = unpack(zeroExtend(b4));
UInt#(6) y_u = (r_u >> 2) + (g_u >> 1) + (b_u >> 2);
return truncate(y_u);
endfunction // lum4
// -------------------- ONLY API: starLevel4 --------------------
// returns 0..starWhite at star positions, else 0
function Bit#(4) starLevel4(U16 sframe, U10 sx, U10 sy,
Bit#(4) bg_r4, Bit#(4) bg_g4, Bit#(4) bg_b4);
Bit#(4) level = 4'h0;
Bool found = False;
// 80個なら線形探索でもOK
for (Integer i = 0; i < valueOf(NumStars); i = i + 1) begin
StarXY s = starTable[i];
if (!found && (s.x == sx) && (s.y == sy)) begin
// ---- random analog twinkle (only for candidates) ----
UInt#(16) phu = phaseXY(sx, sy);
UInt#(16) phu2 = mix16(phu ^ zeroExtend(sx) ^ (zeroExtend(sy) << 3));
// 8 rate groups (0..7)
UInt#(3) rate = truncate(phu2 >> 8);
// background dependent threshold
UInt#(4) y4 = lum4(bg_r4, bg_g4, bg_b4);
UInt#(4) dark4 = 4'd15 - y4; // 0..15
// brightでもthが上がりすぎない(bright:220、dark:190)
U8 th0 = 8'd220 - (zeroExtend(dark4) << 1);
// 星ごとの微小オフセット(0..7)で「領域ごとの偏り」を壊す
UInt#(3) bias3 = truncate(phu);
U8 bias = zeroExtend(bias3);
U8 th1 = (th0 > bias) ? (th0 - bias) : 8'd0;
U8 th = (th1 < thClamp) ? thClamp : th1;
// time base = frame + phase (breaks group sync)
UInt#(16) baseU = zeroExtend(sframe) + phu;
// static shifts (avoid barrel shifter inference)
Integer shBase = baseTickShift + twExtraShift;
UInt#(16) tick;
UInt#(4) fracU;
case (rate)
3'd0: begin tick = baseU >> (shBase + 0); fracU = truncate(baseU >> (shBase + 0 - 4)); end
3'd1: begin tick = baseU >> (shBase + 1); fracU = truncate(baseU >> (shBase + 1 - 4)); end
3'd2: begin tick = baseU >> (shBase + 2); fracU = truncate(baseU >> (shBase + 2 - 4)); end
3'd3: begin tick = baseU >> (shBase + 3); fracU = truncate(baseU >> (shBase + 3 - 4)); end
3'd4: begin tick = baseU >> (shBase + 4); fracU = truncate(baseU >> (shBase + 4 - 4)); end
3'd5: begin tick = baseU >> (shBase + 5); fracU = truncate(baseU >> (shBase + 5 - 4)); end
3'd6: begin tick = baseU >> (shBase + 5); fracU = truncate(baseU >> (shBase + 5 - 4)); end
default: begin tick = baseU >> (shBase + 5); fracU = truncate(baseU >> (shBase + 5 - 4)); end
endcase
UInt#(16) salt0 = unpack(16'h1234);
UInt#(16) salt1 = unpack(16'hBEEF);
U8 r0 = rand8(phu, tick, salt0);
U8 r1 = rand8(phu, tick + 1, salt1);
U8 r = lerp8_frac4(r0, r1, fracU);
U8 diff = (r > th) ? (r - th) : 8'd0;
// 4bitに落とす前に強める(>>1)
// 明るい星が出やすくなる
UInt#(6) lv6 = truncate(diff >> 1);
UInt#(4) lv_u = (lv6 > 6'd15) ? 4'd15 : truncate(lv6);
// thresholdをちょっと超えただけでも“点いた”ことが見えるよう最低1
if ((diff != 0) && (lv_u == 0)) lv_u = 4'd1;
// 最大輝度を starWhite で抑制 UInt#(4) sw_u = unpack(starWhite);
if (lv_u > sw_u) lv_u = sw_u;
level = pack(lv_u);
found = True;
end
end
return level;
endfunction // starLevel4
2.2 アルゴリズム
昨日の記事の簡単なロジックから随分複雑になってしまいました。固定点滅から自然な瞬きへ、ChatGPT が強化した点は次の 3 つです。
- 固定の周期でオンオフする方式は、位相違いが見えて「交互」「順繰り」の規則性が出やすい。そこで、周期に依存する点滅をやめ、乱数ベースの瞬きに置き換えた
- 乱数を毎フレーム独立にすると高速にチラつくので、時間方向に相関を入れた。具体的には、ゆっくり更新される乱数を使い、さらに隣接する時間区間の値を補間して、明るさが滑らかに移るようにした
- 出力を二値ではなく段階的な明るさにして、星が「点滅」ではなく「明るくなったり暗くなったり」するアナログ感を出した
3. 完成画面
mp4 は貼れないので gif を貼ります。解像度が低く、コマ落ちする gif 画面だと分かりづらいのですが、昨日の記事のような機械的な点滅ではなく、明るさが滑らかに上下するようになっています。周期も不規則になり、見た目の「作り物感」がかなり減りました。
ただし、最大輝度にすると前景のゲーム画面を邪魔するので、星の輝度は落としてあり、それもこの画面で星が見づらい原因となっています。
4. さいごに
以前、過去記事で
「巨人の肩に乗る」という英語由来のフレーズがありますが、まさにBSVは巨人であり新規開発がとても楽にできます。巨人の肩の高さまで登る苦労はあるものの、一旦登ってしまえばそこからは楽な道筋となります。もうverilogでステートマシンを苦労して書く必要はありません。
と書きました。当時は「巨人の肩まで登る苦労」が普及の壁だと思っていましたが、今回あらためて実感したのは、その“登る”部分に ChatGPT が効くということです。今回の BSV コードは、実質 100% ChatGPT が作成しました。
つまり、地上から巨人の頭へ直接つながる梯子を手に入れたような感覚です。



