🏠 课程总览 > 第05课
第05课: 精灵移动
16×16像素精灵每帧移动1像素,含动画循环
🏆 精灵每帧移动1像素
✅ Verilator仿真验证通过
📖 核心概念
- 精灵(Spirit):游戏中可移动的2D图形对象。16×16是8bit时代的标准精灵尺寸
- 帧动画:VSYNC信号作为帧触发,每次VSYNC将精灵位置更新1像素。60FPS下精灵每秒移动60像素
- 边界回绕:精灵移出屏幕右侧后回到左侧,形成无限循环效果
💡 关键思路:本课的核心是精灵(Spirit)——游戏中可移动的2D图形对象。16×16是8bit时代的标准精灵尺寸
💻 Verilog设计代码
设计模块源码——这是你真正要理解的硬件逻辑:
// 第05课: 精灵移动 - 精灵每帧移动1像素
// 第5课: 精灵移动 - 精灵每帧移动1像素
module sprite_move (
input wire clk,
input wire rst_n,
input wire [9:0] hcount,
input wire [9:0] vcount,
input wire video_on,
input wire frame_start,
output reg [2:0] rgb
);
reg [9:0] sprite_x;
reg [9:0] sprite_y;
// Sprite is 16x16 pixels
localparam SPRITE_W = 16;
localparam SPRITE_H = 16;
// Sprite pattern: simple smiley face (stored as bits)
reg [15:0] sprite_row [0:15];
integer i;
initial begin
// Simple 16x16 pattern
sprite_row[0] = 16'b0000011111100000;
sprite_row[1] = 16'b0000100000010000;
sprite_row[2] = 16'b0001000000001000;
sprite_row[3] = 16'b0010001100010000;
sprite_row[4] = 16'b0010001100010000;
sprite_row[5] = 16'b0100000000000100;
sprite_row[6] = 16'b0100000000000100;
sprite_row[7] = 16'b0100010000010100;
sprite_row[8] = 16'b0100001000100100;
sprite_row[9] = 16'b0100000000000100;
sprite_row[10] = 16'b0010000000001000;
sprite_row[11] = 16'b0010001111001000;
sprite_row[12] = 16'b0001000000010000;
sprite_row[13] = 16'b0000100000010000;
sprite_row[14] = 16'b0000011111100000;
sprite_row[15] = 16'b0000000000000000;
end
wire [3:0] sprite_col_idx = hcount[3:0] - sprite_x[3:0];
wire [3:0] sprite_row_idx = vcount[3:0] - sprite_y[3:0];
wire in_sprite = (hcount >= sprite_x && hcount < sprite_x + SPRITE_W &&
vcount >= sprite_y && vcount < sprite_y + SPRITE_H);
wire sprite_pixel = in_sprite && sprite_row[sprite_row_idx][15-sprite_col_idx];
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sprite_x <= 10'd100;
sprite_y <= 10'd200;
rgb <= 3'b000;
end else begin
// Move sprite right 1 pixel per frame
if (frame_start) begin
if (sprite_x >= 640 - SPRITE_W)
sprite_x <= 0;
else
sprite_x <= sprite_x + 1;
end
if (video_on) begin
if (sprite_pixel)
rgb <= 3'b110; // Yellow sprite
else
rgb <= 3'b000; // Black background
end else
rgb <= 3'b000;
end
end
endmodule
🧪 测试平台(Testbench)
testbench = 你的"手柄+屏幕",模拟输入、验证输出:
/* verilator lint_off WIDTHEXPAND */
/* verilator lint_off WIDTHTRUNC */
/* verilator lint_off UNOPTFLAT */
/* verilator lint_off WIDTHEXPAND */
/* verilator lint_off WIDTHTRUNC */
/* verilator lint_off UNOPTFLAT */
module tb;
reg clk, rst_n, frame_start;
reg [9:0] hcount, vcount;
reg video_on;
wire [2:0] rgb;
sprite_move uut (
.clk(clk), .rst_n(rst_n),
.hcount(hcount), .vcount(vcount),
.video_on(video_on), .frame_start(frame_start),
.rgb(rgb)
);
always clk = #10 ~clk;
reg [9:0] prev_x;
integer frame_count;
initial begin
$dumpfile("sim.vcd"); $dumpvars(0, tb);
clk = 0; rst_n = 0; frame_start = 0;
hcount = 0; vcount = 0; video_on = 0;
repeat(5) @(posedge clk); rst_n = 1;
$display("=== 精灵移动仿真 ===");
$display("精灵每帧移动1像素");
$display("");
// Test 1: Initial position
$display("--- 测试1: 初始位置 ---");
repeat(3) @(posedge clk);
$display(" 精灵初始X=%0d", uut.sprite_x);
if (uut.sprite_x == 100) $display(" ✅ 精灵初始X=100");
else $display(" ❌ 精灵初始X=%0d(期望100)", uut.sprite_x);
// Test 2: Move 1 pixel per frame
$display("");
$display("--- 测试2: 每帧移动1像素 ---");
prev_x = uut.sprite_x;
frame_start = 1; @(posedge clk); frame_start = 0; repeat(3) @(posedge clk);
$display(" 帧前X=%0d, 帧后X=%0d, 移动=%0d", prev_x, uut.sprite_x, uut.sprite_x - prev_x);
if (uut.sprite_x == prev_x + 1) $display(" ✅ 精灵移动1像素");
else $display(" ❌ 精灵移动%0d像素", uut.sprite_x - prev_x);
// Test 3: Multiple frames
$display("");
$display("--- 测试3: 连续10帧 ---");
prev_x = uut.sprite_x;
for (frame_count = 0; frame_count < 10; frame_count = frame_count + 1) begin
frame_start = 1; @(posedge clk); frame_start = 0; repeat(3) @(posedge clk);
end
$display(" 10帧后X=%0d (起始%0d+10=%0d)", uut.sprite_x, prev_x, prev_x + 10);
if (uut.sprite_x == prev_x + 10) $display(" ✅ 连续10帧移动10像素");
else $display(" ❌ 连续10帧移动%0d像素", uut.sprite_x - prev_x);
// Test 4: Wrap around
$display("");
$display("--- 测试4: 边界回绕 ---");
// Force sprite near right edge
uut.sprite_x = 624; // 640 - 16 = 624
repeat(3) @(posedge clk);
prev_x = uut.sprite_x;
frame_start = 1; @(posedge clk); frame_start = 0; repeat(3) @(posedge clk);
$display(" 边界前X=%0d, 边界后X=%0d", prev_x, uut.sprite_x);
if (uut.sprite_x == 0) $display(" ✅ 到达右边界后回绕到X=0");
else $display(" ❌ 回绕错误(期望0, 实际%0d)", uut.sprite_x);
// Test 5: Sprite visible
$display("");
$display("--- 测试5: 精灵渲染 ---");
uut.sprite_x = 50;
hcount = 55; vcount = 205; video_on = 1;
repeat(5) @(posedge clk);
$display(" 精灵区域像素: RGB=%b", rgb);
if (rgb == 3'b110 || rgb == 3'b000)
$display(" ✅ 精灵区域内像素正确(黄色或透明)");
else
$display(" ❌ 精灵像素异常");
$display("");
$display("✅ 精灵每帧移动1像素验证通过!");
$display("🏆 成就解锁: 精灵每帧移动1像素!");
$finish;
end
endmodule
✅ 仿真输出
运行 verilator --cc *.sv --exe sim_main.cpp --top-module tb --timing --trace --build -j 4 -o sim 后的输出:
=== 精灵移动仿真 ===
精灵每帧移动1像素
--- 测试1: 初始位置 ---
精灵初始X=100
✅ 精灵初始X=100
--- 测试2: 每帧移动1像素 ---
帧前X=100, 帧后X=101, 移动=1
✅ 精灵移动1像素
--- 测试3: 连续10帧 ---
10帧后X=111 (起始101+10=111)
✅ 连续10帧移动10像素
--- 测试4: 边界回绕 ---
边界前X=624, 边界后X=0
✅ 到达右边界后回绕到X=0
--- 测试5: 精灵渲染 ---
精灵区域像素: RGB=000
✅ 精灵区域内像素正确(黄色或透明)
✅ 精灵每帧移动1像素验证通过!
🏆 成就解锁: 精灵每帧移动1像素!
- tb.sv:85: Verilog $finish
🔧 编译和运行
# 编译
verilator --cc *.sv --exe sim_main.cpp --top-module tb --timing --trace \
--build -j 4 -o sim \
-Wno-WIDTHEXPAND -Wno-WIDTHTRUNC -Wno-UNOPTFLAT \
-Wno-TIMESCALEMOD -Wno-STMTDLY -Wno-WIDTH \
-Wno-UNSIGNED -Wno-SELRANGE -Wno-BLKLOOPINIT
# 运行
./obj_dir/sim
# 查看波形(可选)
gtkwave sim.vcd
🎮 实战步骤
1
精灵图案:16×16位图存储为16个16位寄存器。1表示精灵像素,0表示透明
2
位置更新:frame_start脉冲到来时sprite_x+1。这是60FPS游戏的基本逻辑
3
边界检测:当sprite_x>=640-16=624时,下一帧回到x=0。640-16是为了防止精灵显示不完整
4
碰撞渲染:判断当前像素是否在精灵区域内,再查精灵位图决定是否绘制
🎮 游戏开发知识
精灵表:实际游戏中多个精灵帧存放在一张纹理图(Sprite Sheet)中,通过UV坐标选择当前帧
硬件精灵:NES/Famicom的PPU有硬件精灵支持,最多64个8×8或8×16精灵,每行最多8个
双线性插值:现代GPU使用双线性插值缩放精灵,但像素风游戏保持最近邻采样以获得锐利边缘
🏆
精灵每帧移动1像素
✅ Verilator仿真验证通过
🧠 知识扩展
精灵表:实际游戏中多个精灵帧存放在一张纹理图(Sprite Sheet)中,通过UV坐标选择当前帧
硬件精灵:NES/Famicom的PPU有硬件精灵支持,最多64个8×8或8×16精灵,每行最多8个
双线性插值:现代GPU使用双线性插值缩放精灵,但像素风游戏保持最近邻采样以获得锐利边缘
⚡ 性能提示
• 使用--trace选项生成VCD波形文件,用GTKWave查看
• 使用-j 4选项并行编译,加快构建速度
• 使用--build选项让Verilator自动调用make
• 大量$display输出会拖慢仿真速度,验证通过后可以减少打印频率