VGA Controller Rework
Motivation
The original HaDes-V VGA controller was a simple bitmap display designed for the Basys 3’s limited resources. For DOOM, three fundamental changes were needed:
-
Double-buffering: DOOM draws every pixel each frame by writing to a framebuffer. If the VGA reads the same buffer that the CPU writes, tearing and visual artifacts are unavoidable. A double-buffered design allows the CPU to render to an off-screen buffer while the VGA displays the completed frame.
-
Native resolution and color format: DOOM’s internal renderer produces 320×200 pixels with 8-bit palette-indexed color. The VGA controller must accept this format directly to avoid software format conversion.
-
DMA-style framebuffer copy: DOOM’s architecture expects a single, unchanging framebuffer after
I_FinishUpdate()is called, it assumes the new frame appears atomically. The hardware blitter bridges this expectation by copying the freshly rendered inactive buffer to the now-inactive bank after a swap, preserving the complete frame content regardless of CPU rendering progress in the next frame.
Memory Layout
The VGA controller maps into the Wishbone address space in a single 32 KB window:
| Address Range | Size | Usage |
|---|---|---|
| 0x0000 – 0x3FFF | 16 KB | Inactive framebuffer: CPU writes into this bank while rendering |
| 0x4000 – 0x7FFF | 16 KB | Active framebuffer: displayed on screen; CPU reads for copy |
| 0x8000 – 0x80FF | 256 B | Palette: 256 entries × 12-bit RGB (4 bits per channel) |
| 0x8100 | 4 B | Control register: bit 0: front buffer select |
Double-Buffering
The two framebuffer banks are stored in a 32,768 * 32-bit BRAM array. The mapping between CPU-visible addresses and physical BRAM addresses is transparently remapped based on the current front buffer selection:
always_comb begin
if (wb_offset[14] == 1'b0)
mapped_fb_addr = {~cpu_front_buffer, wb_offset[13:0]};
else
mapped_fb_addr = {cpu_front_buffer, wb_offset[13:0]};
end
The CPU always writes to the lower 16 KB (offset 0x0000) which is remapped to the inactive bank. The upper 16 KB (offset 0x4000) always reads from the active bank and is used by the hardware copy logic.
Hardware Blitter
After a buffer swap is asserted by the CPU writing to the control register, the swap request is synchronized to the VGA clock domain. The VGA applies the swap at the start of vblank, and the acknowledgment is synchronized back to the CPU domain.
Once the VGA confirms the swap, a hardware copy engine sequentially copies all 16,384 pixels from the active bank to the now-inactive bank. This ensures that the newly inactive bank contains the complete previous frame, so the CPU can begin drawing the next frame without partial-content artifacts:
if (!is_copying && (vga_fb_clk_domain != last_vga_fb_state)) begin
is_copying <= 1;
copy_idx <= 0;
end else if (is_copying) begin
if (copy_read_phase) begin
copy_read_phase <= 0; // Read: framebuffer[active, copy_idx]
end else begin
if (copy_idx == 14'h3FFF) is_copying <= 0;
else begin
copy_idx <= copy_idx + 1;
copy_read_phase <= 1; // Write: framebuffer[inactive, copy_idx]
end
end
end
During the copy, the CPU is stalled if it attempts to write to the framebuffer region (wb_offset < 0x8000) — the ack is withheld until the copy completes. This prevents write-after-write hazards to the buffer being copied from.
VGA Timing Generator
The timing generator produces standard 640×480 at 60 Hz VGA signals:
| Parameter | Pixels |
|---|---|
| Horizontal visible | 640 |
| Horizontal front porch | 16 |
| Horizontal sync | 96 |
| Horizontal back porch | 48 |
| Vertical visible | 480 |
| Vertical front porch | 10 |
| Vertical sync | 2 |
| Vertical back porch | 33 |
DOOM Rendering Pipeline
DOOM’s 320×200 internal resolution is scaled to 640×480 through pixel doubling and letterboxing:
assign doom_x = vga_x[9:1]; // Horizontal: divide by 2
assign doom_y = (vga_y - 10'd40) >> 1; // Vertical: -40 letterbox, divide by 2
Each DOOM pixel occupies a 2×2 block on the 640×480 display, centered vertically with a 40-pixel letterbox at the top and bottom.
Byte Address Calculation
The DOOM pixel address uses a multiply-by-320 layout:
assign doom_byte_addr = (doom_y << 8) + (doom_y << 6) + doom_x; // y*320 + x
The address is then mapped to the active framebuffer:
assign fb_vga_addr = doom_byte_addr[15:2] + (vga_front_buffer ? 15'd16384 : 15'd0);
Pixel Pipeline
The rendering pipeline is 3 stages deep to meet VGA pixel clock timing:
- Byte select: Extract the correct byte (0–3) from the 32-bit BRAM word based on
doom_byte_addr[1:0] - Palette lookup: Index the 256 × 12-bit palette BRAM with the byte value
- Output: Drive R/G/B (4 bits each) to the VGA DAC, gated by the active area signal
The pipeline also delays H-sync and V-sync by the same 3 cycles to maintain alignment with the pixel data.
Wishbone Interface
| Offset | Access | Description |
|---|---|---|
| 0x0000 – 0x3FFF | R/W | Inactive framebuffer (remapped) |
| 0x4000 – 0x7FFF | R | Active framebuffer (read-only) |
| 0x8000 – 0x80FF | R/W | Palette registers (12-bit RGB) |
| 0x8100 | R/W | Control: bit 0 = buffer select, bit 1 = busy flag |
Writing to the control register triggers the buffer swap sequence. The busy flag can be polled to determine when the hardware copy has completed.