FPGA VGA Graphics in Verilog Part 2

Introduction

Welcome back to my FPGA graphics tutorial series using the Digilent Arty or Basys3 boards. In part 1 we created a VGA module and used it to animate squares on screen. In this second part, we introduce bitmapped displays. By the end of this tutorial, you'll be able to load your own image into FPGA memory and display it on your monitor.

Feedback to @WillFlux is most welcome. Significantly updated April 2018.

Requirements

The requirements for this part are the same as part 1:

  1. Digilent Arty or Basys3 board
  2. VGA Pmod if using the Arty (Basys3 has VGA built-in)
  3. VGA capable monitor & cable
  4. Micro USB cable to program and power the Arty board
  5. Xilinx Vivado installed
  6. Arty or Basys3 board file installed, so Vivado knows your board specification

Other FPGA Boards

This tutorial requires at least 1,350 Kbits of on-board FPGA memory (block or distributed ram). Provided you can meet the ram requirement it should be relatively easy to adapt this tutorial to other boards:

  1. Hardware I/O: update hardware ports in top, such as CLK and VGA_R, to match your board.
  2. Clock: if your board clock isn't 100 MHz, you need to update the pixel clock code in top.
  3. VGA Outputs: if your VGA output isn't 4-bits per colour, adjust VGA assign statements in top.

Bitmaps

A bitmapped display is backed by a memory array: each pixel corresponds to a location in memory. To create the display we read out the value of the current pixel from its memory location. To set the colour of a pixel, we modify the contents of its associated memory location. Bitmaps provides two significant benefits: we're free to create sophisticated graphics using whatever technique we like, and the setting of pixel colour is separated from the actual screen drawing process. The big downside of a bitmapped display is we need significant memory to store our pixel data.

Memory Requirements: Resolution and Colour Depth

The first consideration is the capability of our VGA output: it supports four bits for each of red, green, and blue for a total of 12 bits per pixel. If we were to use 12 bits per pixel with a standard 640x480 VGA display our memory array would need to be 3,600 Kbits: 640 x 480 x 12 bits = 3,686,400 bits. However, our XC7A35T FPGA only has 1,800 Kbits of block ram. While the Arty board also includes 256 MB of DDR3L memory, the Basys3 does not, and using DDR memory would significantly add to the complexity of our design.

To allow our design to fit within block ram, we lower our screen resolution to 640 x 360 and limit ourselves to 6-bit colour: 640 x 360 x 6 bits = 1,350 Kbits. 640 x 360 has the added benefit of having a 16:9 widescreen ratio that matches modern monitors and TVs.

64 Colours

If each pixel is represented by 6 bits, then it can have 26 or 64 possible colours. If we allocate 2 bits to each of red, green and blue, then each colour can have one of four binary values: 00, 01, 10, or 11. However, our VGA output is 12-bit: it expects 4 bits per pixel (zero to fifteen). To use the full brightness of the display we need to extend our 2-bit colours to 4-bits; to do this we duplicate the bits resulting in the four possible values: 0000 (zero), 0101 (five), 1010 (ten) and 1111 (fifteen).

Modern computer displays are usually 24-bit, that is they can display 16.7 million colours. To get a feel for what our fixed 64 colours look like I've created a swatch:

Having gone to the trouble to look at how we might directly drive the display using a 6-bit value we're not going to do it! Instead, we're going to use a little indirection to extend our colour possibilities to the full range of 4,096.

Indirection: Indexed Colour & Palettes

Instead of each 6-bit value representing a colour, it can represent a colour in a palette lookup table. If a particular pixel has the value 001010 (ten), then we consult the tenth entry in the colour table, which contains the 12-bit colour to use. While we're still limited to a total of 64 colours they can now be any 64 from the 4,096 colours the VGA output is capable of producing. For example, a picture of a forest might have many greens, while one of a city would use more greys. This design was common in older computers, for example, the original Amiga chipset supported 32 colours from a possible 4,096: very similar to our design! The GIF and PNG formats still make use of this approach to squeeze the best quality out of 256-colour images.

If you're interested in learning more about different colour palettes, there's an excellent List of color palettes page on Wikipedia.

That's enough colour theory, let's get on with creating an image!

Preparing an Image for FPGA Memory

In order to display our image we need a mechanism to load it and its palette into our design. Verilog supports a method called $readmemh that can load values into memory from a text file of hex values. I've created a handy Python script (img2fmem.py) that generates suitable files for images and their palettes. It can convert a source image into 6-bit colour with an optimised 12-bit palette.

If you don't want to prepare your own image then you can get download a ready-made one and skip to the next section.

Using img2fmem.py

Grab a copy of img2fmem.py from the FPGATools repository on Github.

img2fmem.py requires the Pillow package to run. You can install this with Python's pip tool:

pip install pillow

Use an image editor to crop/resize your image to 640 x 360 pixels and save it in JPEG, PNG, BMP or TIFF format. NB. It's important your image is exactly 640 x 360 pixels otherwise it won't display correctly.

Once you have a suitable source image you can run the following (replacing my-image.png with your image filename):

python img2fmem.py my-image.png 6 mem

Once it completes you should find three new files in the same directory as the source image:

  • my-image_preview.png - a normal PNG you can view to preview the image conversion
  • my-image.mem - a text hex version of the image
  • my-image_palette.mem - a text hex version of the image palette

Load up the preview in a web browser or image editor to check the conversion looks OK.

If you open the palette file in a text editor you'll see the sixty-four 12-bit palette values for your image.

Now we have a prepared image it's time to load it into memory.

Memory Mapped

Create a new RTL project in Vivado called vga02 with the Arty or Basys 3 board as the target. If you need advice on project creation see part 1 of my introductory tutorial series.

We need to create a memory array to hold our image data. To do this, we'll use a simple sram module suitable for use with FPGA block ram.

Add a design source called sram.v:

module sram #(parameter ADDR_WIDTH = 8, DATA_WIDTH = 8, DEPTH = 256, MEMFILE="") (
    input wire i_clk,
    input wire [ADDR_WIDTH-1:0] i_addr, 
    input wire i_write,
    input wire [DATA_WIDTH-1:0] i_data,
    output reg [DATA_WIDTH-1:0] o_data 
    );

    reg [DATA_WIDTH-1:0] memory_array [0:DEPTH-1]; 

    initial begin
        $display("Loading memory init file '" + MEMFILE + "' into memory array.");
        $readmemh(MEMFILE, memory_array);
    end

    always @ (posedge i_clk)
    begin
        if(i_write) begin
            memory_array[i_addr] <= i_data;
        end
        else begin
            o_data <= memory_array[i_addr];
        end     
    end
endmodule

If you want to learn more about the use of block ram check out my Verilog block ram recipe.

VGA 640x360

We're going to use a modified version of our VGA driver module from part 1. It changes the active vertical resolution to 360 pixels while maintaining standard 640x480 VGA timings. This means our output will still work with a standard VGA display: the 360 vertical pixels will have black bars above and below (letterboxing).

The are four changes:

  • We add an o_active output which is high during active pixel drawing
  • We add the parameter for vertical active start VA_STA with a value of 60
  • We change the value of vertical active end VA_END to be 420
  • We subtract VA_STA from the active pixel value on the assign o_y = line.

Create a new module called vga640x360.v with the following content:

module vga640x360(
    input wire i_clk,       // base clock
    input wire i_pix_stb,   // pixel clock strobe
    output wire o_hs,       // horizontal sync
    output wire o_vs,       // vertical sync
    output wire o_blanking, // high during blanking interval
    output wire o_active,   // high during active pixel drawing
    output wire o_animate,  // high for one tick at end of active drawing
    output wire [9:0] o_x,  // current pixel x position: 10-bit value: 0-1023
    output wire [8:0] o_y   // current pixel y position:  9-bit value: 0-511
    );

    localparam HS_STA = 16;              // horizontal sync start
    localparam HS_END = 16 + 96;         // horizontal sync end
    localparam HA_STA = 16 + 96 + 48;    // horizontal active pixel start
    localparam VS_STA = 480 + 11;        // vertical sync start
    localparam VS_END = 480 + 11 + 2;    // vertical sync end
    localparam VA_STA = 60;              // vertical active pixel start
    localparam VA_END = 420;             // vertical active pixel end
    localparam LINE   = 800;             // complete line (pixels)
    localparam SCREEN = 524;             // complete screen (lines)

    reg [9:0] h_count = 0;  // line position:   10-bit value: 0-1023
    reg [9:0] v_count = 0;  // screen position: 10-bit value: 0-1023

    // generate sync signals (active low for 640x480)
    assign o_hs = ~((h_count >= HS_STA) & (h_count < HS_END));
    assign o_vs = ~((v_count >= VS_STA) & (v_count < VS_END));

    // keep x and y bound within the active pixels
    assign o_x = (h_count < HA_STA) ? 0 : (h_count - HA_STA);
    assign o_y = (v_count >= VA_END) ? (VA_END - VA_STA - 1) : (v_count - VA_STA);

    // blanking: high within the blanking period
    assign o_blanking = ((h_count < HA_STA) | (v_count > VA_END - 1));

    // active: high during active pixel drawing
    assign o_active = ~((h_count < HA_STA) | (v_count > VA_END - 1) | (v_count < VA_STA));

    // animate: high for one tick at the end of the final active pixel line
    assign o_animate = ((v_count == VA_END - 1) & (h_count == LINE));

    always @ (posedge i_clk)
    begin
        if (i_pix_stb)  // once per pixel
        begin
            if (h_count == LINE)  // end of line
            begin
                h_count <= 0;
                v_count <= v_count + 1;
            end
            else 
                h_count <= h_count + 1;

            if (v_count == SCREEN)  // end of screen
                v_count <= 0;
        end
    end
endmodule

Learn more about video display timings.

Bringing it Together

Create a design source called top.v with the following design.

You need to replace my-image.mem and my-image_palette.mem with the names of the image and palette files you created earlier:

module top(
    input wire CLK,             // board clock: 100 MHz on Arty & Basys 3
    output wire VGA_HS_O,       // horizontal sync output
    output wire VGA_VS_O,       // vertical sync output
    output reg [3:0] VGA_R,     // 4-bit VGA red output
    output reg [3:0] VGA_G,     // 4-bit VGA green output
    output reg [3:0] VGA_B      // 4-bit VGA blue output
    );

    // generate a 25 MHz pixel strobe
    reg [15:0] cnt = 0;
    reg pix_stb = 0;
    always @(posedge CLK)
        {pix_stb, cnt} <= cnt + 16'h4000;  // divide clock by 4: (2^16)/4 = 0x4000

    wire [9:0] x;  // current pixel x position: 10-bit value: 0-1023
    wire [8:0] y;  // current pixel y position:  9-bit value: 0-511
    wire active; // high during active pixel drawing

    vga640x360 display (
        .i_clk(CLK), 
        .i_pix_stb(pix_stb),
        .o_hs(VGA_HS_O), 
        .o_vs(VGA_VS_O), 
        .o_x(x), 
        .o_y(y),
        .o_active(active)
    );

    localparam VRAM_A_WIDTH = 18; 
    localparam VRAM_D_WIDTH = 6; 
    localparam VRAM_DEPTH = 640*360; 

    reg [VRAM_A_WIDTH-1:0] address;
    wire [VRAM_D_WIDTH-1:0] dataout;

    sram #(
        .ADDR_WIDTH(VRAM_A_WIDTH), 
        .DATA_WIDTH(VRAM_D_WIDTH), 
        .DEPTH(VRAM_DEPTH), 
        .MEMFILE("my-image.mem")) 
        vram (
        .i_addr(address), 
        .i_clk(CLK), 
        .i_write(0),  // we're always reading
        .i_data(0), 
        .o_data(dataout)
    );

    reg [11:0] palette [0:63]; 
    reg [11:0] colour;
    initial begin
        $display("Loading palette.");
        $readmemh("my-image_palette.mem", palette);  // update with your filename
    end

    always @ (posedge CLK)
    begin
        address <= y * 640 + x;

        if (active)
            colour <= palette[dataout];
        else    
            colour <= 0;

        VGA_R <= colour[11:8];
        VGA_G <= colour[7:4];
        VGA_B <= colour[3:0];
    end
endmodule

The sram module takes four parameters: the width of data, the number of values to store (the depth), the address width required to access them, and the name of a memory initialization file to load during build. For our display, we use 6-bits as the data width, 640 x 360 as the depth, and 18-bits for the address as 218 is the smallest value greater than 640 x 360.

To learn more about memory initialization, see the FPGA cookbook entry Initialize Memory in Verilog.

Add Image Files to Project

To enable Vivado to find the image file and palette you need to add them to your project. You do this as you would for a design source using "Add Sources" then selecting "Files of type: Memory Initialization Files" then locate my-image.mem and my-image_palette.mem (or whatever you called them). Vivado will automatically identify them as memory files and make them available to your design.

Constraints

Create a constraints file called arty.xdc with the following content:

## Arty Board Constraints
## Based on https://github.com/Digilent/digilent-xdc/blob/master/Arty-Master.xdc

## Clock
set_property -dict {PACKAGE_PIN E3  IOSTANDARD LVCMOS33} [get_ports {CLK}];
create_clock -add -name sys_clk_pin -period 10.00 -waveform {0 5} [get_ports {CLK}];

## VGA Pmod Header JB
set_property -dict {PACKAGE_PIN E15 IOSTANDARD LVCMOS33} [get_ports {VGA_R[0]}];
set_property -dict {PACKAGE_PIN E16 IOSTANDARD LVCMOS33} [get_ports {VGA_R[1]}];
set_property -dict {PACKAGE_PIN D15 IOSTANDARD LVCMOS33} [get_ports {VGA_R[2]}];
set_property -dict {PACKAGE_PIN C15 IOSTANDARD LVCMOS33} [get_ports {VGA_R[3]}];
set_property -dict {PACKAGE_PIN J17 IOSTANDARD LVCMOS33} [get_ports {VGA_B[0]}];
set_property -dict {PACKAGE_PIN J18 IOSTANDARD LVCMOS33} [get_ports {VGA_B[1]}];
set_property -dict {PACKAGE_PIN K15 IOSTANDARD LVCMOS33} [get_ports {VGA_B[2]}];
set_property -dict {PACKAGE_PIN J15 IOSTANDARD LVCMOS33} [get_ports {VGA_B[3]}];

## VGA Pmod Header JC
set_property -dict {PACKAGE_PIN U12 IOSTANDARD LVCMOS33} [get_ports {VGA_G[0]}];
set_property -dict {PACKAGE_PIN V12 IOSTANDARD LVCMOS33} [get_ports {VGA_G[1]}];
set_property -dict {PACKAGE_PIN V10 IOSTANDARD LVCMOS33} [get_ports {VGA_G[2]}];
set_property -dict {PACKAGE_PIN V11 IOSTANDARD LVCMOS33} [get_ports {VGA_G[3]}];
set_property -dict {PACKAGE_PIN U14 IOSTANDARD LVCMOS33} [get_ports {VGA_HS_O}];
set_property -dict {PACKAGE_PIN V14 IOSTANDARD LVCMOS33} [get_ports {VGA_VS_O}];

If you're using the Basys3 board, you need to modify the constraints for your board. Change the pins for the clock and VGA ports. See the Basys3 reference manual and Basys3 master XDC for details.

Build & Program

Run synthesis, implementation, bitstream generation. See the FPGA introductory post if you need a reminder on how to do this.

Next, hook up your VGA Pmod to the middle two connectors (JB and JC) on your Arty and use your VGA cable to connect your monitor to the VGA Pmod. Basys3 users can just connect the VGA cable directly to their board. Finally, connect your board to your computer via USB and program it with vga02/vga02.runs/impl_1/top.bit.

You should see your bitmap image displayed. If you image is distorted or the wrong colours make sure your image file is exactly 640 x 360 and that you've loaded the correct palette file in top.v.

What's Next?

Now we've got a bitmapped display to play with we're ready to experiment with sprites. That'll be the subject of the next post. Follow @WillFlux for updates.

©2018 Will Green.

Graphics Credit: The sample spaceship game image comes from KenneyNL and is public domain.