Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. 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.

  2. 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.

  3. 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 RangeSizeUsage
0x0000 – 0x3FFF16 KBInactive framebuffer: CPU writes into this bank while rendering
0x4000 – 0x7FFF16 KBActive framebuffer: displayed on screen; CPU reads for copy
0x8000 – 0x80FF256 BPalette: 256 entries × 12-bit RGB (4 bits per channel)
0x81004 BControl 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:

ParameterPixels
Horizontal visible640
Horizontal front porch16
Horizontal sync96
Horizontal back porch48
Vertical visible480
Vertical front porch10
Vertical sync2
Vertical back porch33

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:

  1. Byte select: Extract the correct byte (0–3) from the 32-bit BRAM word based on doom_byte_addr[1:0]
  2. Palette lookup: Index the 256 × 12-bit palette BRAM with the byte value
  3. 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

OffsetAccessDescription
0x0000 – 0x3FFFR/WInactive framebuffer (remapped)
0x4000 – 0x7FFFRActive framebuffer (read-only)
0x8000 – 0x80FFR/WPalette registers (12-bit RGB)
0x8100R/WControl: 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.