Project F

Ad Astra

Published · Updated

This collection of related demos combines some of my earliest FPGA designs from 2018: simple sprites and an animated starfield generated with a linear-feedback shift register. I don’t recommend using this approach to sprites in new designs; check out the hardware sprites tutorial instead.

Requirements

For these demos you need an FPGA board with video output. I’ll be working with the Digilent Arty A7, but it should be easy to adapt this design to other boards. It helps to be comfortable with programming your FPGA board and reasonably familiar with Verilog.

If you’ve not played with FPGA graphics before, check out Beginning FPGA Graphics.

Computer Space

Computer Space was the first video arcade game: it features a simple backdrop of stars over which the player’s rocket battles two flying saucers. The backdrop may have been simple, but this game was released in 1971, when a “cheap” computer, such as the Data General Nova, cost around $8,000 (c. $40,000 in 2020). Unable to find anything both fast and cheap, developers Nolan Bushnell and Ted Dabney created their own custom hardware instead. It’s strangely hard to find details of the Computer Space logic design on the Internet, but TTL 7400s were used.

Computer Space Cabinet
Computer Space had an amazing fibreglass cabinet. You can see more photos at Marvin’s Marvelous Mechanical Museum (courtesy of the Wayback Machine).

Linear-Feedback Shift Register

I don’t know how Computer Space created its starfield, but we’re going to use a linear-feedback shift register (henceforth LFSR). Rather like field programmable gate arrays themselves, their name makes LFSRs sound arcane and inscrutable; happily for us, this is not the case.

An LFSR can create a pseudorandom number sequence in which every number appears just once. For example, an 8-bit LSFR can generate all the numbers from 1-255 in a repeatable sequence that seems random. The logic for an LFSR can be written in a single line of Verilog:

sreg <= {1'b0, sreg[7:1]} ^ (sreg[0] ? 8'b10111000 : 8'b0);

The first part of the statement right-shifts the shift-register one bit. In the second part, we check the value of the bit we shifted: this is the feedback. If the feedback is true, we XOR the whole shift register with a magic pattern of bits known as taps. Values at the bit positions of the taps are flipped by the XOR. The initial value of an LFSR is known as the seed.

For example, an 8-bit LFSR with a seed of 169:

  • 10101001 - seed value (169)
  • 01010100 - after right shift (bit shifted out was 1)
  • 01010100 XOR 10111000 - XOR with taps
  • 11101100 - 2nd value (236)
  • 01110110 - after right shift (bit shifted out was 0)
  • 01110110 XOR 00000000 - XOR with 0
  • 01110110 - 3rd value (118)

For an 8-bit LFSR, we set tap bits 8, 6, 5, and 4. To find taps for other lengths, you can refer to the feedback polynomials on Wikipedia.

To create a starfield, we need a sequence that’s the same length as the number of pixels we’re drawing. If we iterate through our shift register every time we get to a new pixel, then each pixel will always be associated with the same number in the shift register. We could take a single bit and draw a star if it were true, but that would cover half the pixels on the screen with stars; instead, we only draw a star if a group of bits are all 1.

We’ll start with a 17-bit LFSR, as its longest sequence, 217-1, fits within 640x480. If we draw this within an area of 512x256, which has 217 pixels, then the starfield will move left by one pixel every frame.

Galois Linear-Feedback Shift Register [lfsr.sv]:

module lfsr #(
    parameter LEN=8,                   // shift register length
    parameter TAPS=8'b10111000         // XOR taps
    ) (
    input  wire logic clk,             // clock
    input  wire logic rst,             // reset
    input  wire logic en,              // enable
    input  wire logic [LEN-1:0] seed,  // seed (uses default seed if zero)
    output      logic [LEN-1:0] sreg   // lfsr output
    );

    always_ff @(posedge clk) begin
        if (en)  sreg <= {1'b0, sreg[LEN-1:1]} ^ (sreg[0] ? TAPS : {LEN{1'b0}});
        if (rst) sreg <= (seed != 0) ? seed : {LEN{1'b1}};
    end
endmodule

With modules from the Verilog Lib we can draw to the Arty VGA output - [xc7/top_lfsr.sv]:

module top_lfsr (
    input  wire logic clk_100m,     // 100 MHz clock
    input  wire logic btn_rst_n,    // reset button (active low)
    output      logic vga_hsync,    // horizontal sync
    output      logic vga_vsync,    // vertical sync
    output      logic [3:0] vga_r,  // 4-bit VGA red
    output      logic [3:0] vga_g,  // 4-bit VGA green
    output      logic [3:0] vga_b   // 4-bit VGA blue
    );

    // generate pixel clock
    logic clk_pix;
    logic clk_pix_locked;
    logic rst_pix;
    clock_480p clock_pix_inst (
       .clk_100m,
       .rst(!btn_rst_n),  // reset button is active low
       .clk_pix,
       .clk_pix_5x(),  // not used for VGA output
       .clk_pix_locked
    );
    always_ff @(posedge clk_pix) rst_pix <= !clk_pix_locked;  // wait for clock lock

    // display sync signals and coordinates
    localparam CORDW = 16;
    logic signed [CORDW-1:0] sx, sy;
    logic hsync, vsync;
    logic de;
    display_480p #(.CORDW(CORDW)) display_inst (
        .clk_pix,
        .rst_pix,
        .sx,
        .sy,
        .hsync,
        .vsync,
        .de,
        .frame(),
        .line()
    );

    logic sf_area;
    always_comb sf_area = (sx < 512 && sy < 256);

    // 17-bit LFSR
    logic [16:0] sf_reg;
    lfsr #(
        .LEN(17),
        .TAPS(17'b10010000000000000)
    ) lsfr_sf (
        .clk(clk_pix),
        .rst(rst_pix),
        .en(sf_area && de),
        .seed(0),  // use default seed
        .sreg(sf_reg)
    );

    // adjust star density (~512 stars for AND 8 bits with 512x256)
    logic star;
    always_comb star = &{sf_reg[16:9]};

    // VGA output
    always_ff @(posedge clk_pix) begin
        vga_hsync <= hsync;
        vga_vsync <= vsync;
        vga_r <= (de && sf_area && star) ? sf_reg[3:0] : 4'h0;
        vga_g <= (de && sf_area && star) ? sf_reg[3:0] : 4'h0;
        vga_b <= (de && sf_area && star) ? sf_reg[3:0] : 4'h0;
    end
endmodule

Building the Demo
In the Ad Astra section of the git repo, you’ll find the design files and build instructions.

A Screen Full of Sky

If we use the maximum sequence of an LFSR, then our starfield is limited to a few sizes. To fill the screen, we choose an LFSR that produces a sequence longer than our number of pixels, then restart it when we reach the end of the screen. There are two ways to do this:

  1. Find the LFSR value at the point we want to restart
  2. Use a separate counter

The first option is extremely efficient when it comes to logic. As each value appears only once, it uniquely describes a position in the sequence. Historically, LFSR were used as counters, including on FPGAs. Xilinx has a nice application note describing this: XAPP210.

The second option requires separate counter logic, but on a contemporary FPGA, the cost is minimal. The advantage of this approach is we can easily adjust the direction and speed of the starfield by counting a little more or a little less. This is the approach we’ll use.

We’re going to want a few starfields, so let’s create a dedicated module [starfield.sv]:

module starfield #(
    parameter H=800,
    parameter V=525,
    parameter INC=-1,
    parameter SEED=21'h1FFFFF,
    parameter MASK=21'hFFF
    ) (
    input  wire logic clk,           // clock
    input  wire logic en,            // enable
    input  wire logic rst,           // reset
    output      logic sf_on,         // star on
    output      logic [7:0] sf_star  // star brightness
    );

    localparam RST_CNT = H * V + INC - 1;  // counter starts at zero, so sub 1
    logic [20:0] sf_reg, sf_cnt;

    always_ff @(posedge clk) begin
        if (en) begin
            sf_cnt <= sf_cnt + 1;
            if (sf_cnt == RST_CNT) sf_cnt <= 0;
        end
        if (rst) sf_cnt <= 0;
    end

    // select some bits to form stars
    always_comb begin
        sf_on = &{sf_reg | MASK};
        sf_star = sf_reg[7:0];
    end

    lfsr #(
        .LEN(21),
        .TAPS(21'b101000000000000000000)
        ) lsfr_sf (
        .clk,
        .rst(sf_cnt == 21'b0),
        .en,
        .seed(SEED),
        .sreg(sf_reg)
    );
endmodule

The starfield module defaults to a 21-bit LFSR, which has a maximum sequence of just over two million. The module takes the screen dimensions as parameters H and W: we’re using the full screen, including the blanking interval, so the starfield doesn’t immediately repeat. The MASK allows us to control the stellar density, the more 1s in the mask, the more stars there will be. Finally, the module outputs an 8-bit value for star brightness, which can be used to create a more varied starfield.

Into Space

Using our module, we can create multiple starfields at different speeds and densities to give that real in-space feeling. The example top module has three starfields: [xc7/top_starfields.sv].

Try experimenting with the INC and MASK parameters to create different speeds and densities.

Greetings, World!

Our starfields make an ideal backdrop for a greetings demo. Sprites are not ideal for large quantities of text, but do make for a flexible way to animate short messages.

Bitmap Font

If we’re going to display text messages, we need a font. I’ve experimented with a few simple bitmap fonts in the past, but Unscii is one of the best. It’s available in 8x8 and 8x16/16x16 pixels with thousands of glyphs for many languages and those for ASCII art. Plus, the GNU Unifont hexdump version is trivial to convert to readmemh format for use with Verilog.

The full list of glyphs is too large to be held in internal FPGA memory. For the purposes of this demo, I’ve created two subsets of the Unscii font: one for upper-case basic Latin (including punctuation and numbers), and one for Hiragana (without marks):

Hex Glyphs

If you look at the entry for ‘F’ in the Latin memory file you won’t find an 8x8 array of 1s and 0s:
7E 60 60 7C 60 60 60 00 // U+0046 (F)

Each eight-pixel line of the glyph is represented by two hex digits, so 0x7E is the first line of pixels, 0x60 the second etc. There are two ways these lines could be drawn: most significant bit (MSB) first, or least significant bit (LSB) first. For our ‘F’ glyph the results of MSB and LSB first are shown below.

 ######         ######
 ##                 ##
 ##                 ##
 #####           #####
 ##                 ##
 ##                 ##
 ##                 ##

For this font we want to draw the MSB first, but other font data might require the LSB first; we provide support for both options in our module (discussed below).

Choosing Your Own Characters
You can easily create your own font version with different characters: check out the hex source on the Unscii site. Use VS Code Column Selection Mode to quickly turn Unifont hex into readmemh format. Just be aware you can’t mix different glyph sizes with our sprite design, and watch out for memory usage.

Shared Memory Bus

We want to share a set of font glyphs amongst multiple sprite instances. Otherwise each sprite would needs its own copy of every glyph it wanted to display, which would quickly get messy and memory hungry.

Sharing requires arbitration between the different sprite instances: only one sprite can read from the memory at a time. We control access by adding a dma_avail signal to the sprite module: the sprite waits for this signal before reading from memory. To avoid potential clashes, we read the required data for each sprite in the blanking interval, then it can freely draw whenever it likes on the following line.

Our fonts are monochrome: each pixel is either 0 or 1, so we could read one bit at a time over a one-bit bus. However, as we’re reading the sprite during the blanking interval there’s no need to read a pixel at a time; it’s more efficient to read a whole line of 8 or 16 pixels (for the Hiragana glyphs) at a time. By reading a whole sprite line in a single clock, we make very light use of the memory bus.

Most or Least Significant?

As we discussed in Hex Glyphs, above, a font may be drawn most or least significant bit first. Because we load an entire glyph line into our sprite at a time, it’s easy to reverse the bits with a for loop if required:

    READ_MEM: begin
        if (LSB) begin
            spr_line <= data_in;  // assume read takes one clock cycle
        end else begin  // reverse if MSB is left-most pixel
            for (i=0; i<WIDTH; i=i+1)
                spr_line[i] <= data_in[(WIDTH-1)-i];
        end
    end

On the surface, for loops in Verilog seem the same as those in software; this is misleading.

A for loop in Verilog duplicates logic, so the above bit reversal is equivalent to (WIDTH=8):

    spr_line[0] <= data_in[7];
    spr_line[1] <= data_in[6];
    spr_line[2] <= data_in[5];
    spr_line[3] <= data_in[4];
    spr_line[4] <= data_in[3];
    spr_line[5] <= data_in[2];
    spr_line[6] <= data_in[1];
    spr_line[7] <= data_in[0];

All eight bits are read in one cycle. Using the for loop doesn’t change the design, but makes writing it much more compact and less error prone. We’ll make further use of for loops shortly, to handle multiple sprites.

Sprite Module

Our Ad Astra sprite module [sprite.sv]:

module sprite #(
    parameter WIDTH=8,         // graphic width in pixels
    parameter HEIGHT=8,        // graphic height in pixels
    parameter SCALE_X=1,       // sprite width scale-factor
    parameter SCALE_Y=1,       // sprite height scale-factor
    parameter LSB=1,           // first pixel in LSB
    parameter CORDW=16,        // screen coordinate width in bits
    parameter ADDRW=9          // width of graphic memory address bus
    ) (
    input  wire logic clk,                  // clock
    input  wire logic rst,                  // reset
    input  wire logic start,                // start control
    input  wire logic dma_avail,            // memory access control
    input  wire logic signed [CORDW-1:0] sx,    // horizontal screen position
    input  wire logic signed [CORDW-1:0] sprx,  // horizontal sprite position
    input  wire logic [WIDTH-1:0] data_in,  // data from external memory
    output      logic [ADDRW-1:0] pos,      // sprite line position
    output      logic pix,                  // pixel colour to draw (0 or 1)
    output      logic drawing,              // sprite is drawing
    output      logic done                  // sprite drawing is complete
    );

    logic [WIDTH-1:0] spr_line;  // local copy of sprite line

    // position within sprite
    logic [$clog2(WIDTH)-1:0]  ox;
    logic [$clog2(HEIGHT)-1:0] oy;

    // scale counters
    logic [$clog2(SCALE_X)-1:0] cnt_x;
    logic [$clog2(SCALE_Y)-1:0] cnt_y;

    enum {
        IDLE,       // awaiting start signal
        START,      // prepare for new sprite drawing
        AWAIT_DMA,  // await access to memory
        READ_MEM,   // read line of sprite from memory
        AWAIT_POS,  // await horizontal position
        DRAW,       // draw pixel
        NEXT_LINE,  // prepare for next sprite line
        DONE        // set done signal
    } state, state_next;

    integer i;  // for bit reversal in READ_MEM

    always_ff @(posedge clk) begin
        state <= state_next;  // advance to next state

        case (state)
            START: begin
                done <= 0;
                oy <= 0;
                cnt_y <= 0;
                pos <= 0;
            end
            READ_MEM: begin
                if (LSB) begin
                    spr_line <= data_in;  // assume read takes one clock cycle
                end else begin  // reverse if MSB is left-most pixel
                    for (i=0; i<WIDTH; i=i+1)
                        spr_line[i] <= data_in[(WIDTH-1)-i];
                end
            end
            AWAIT_POS: begin
                ox <= 0;
                cnt_x <= 0;
            end
            DRAW: begin
                if (SCALE_X <= 1 || cnt_x == SCALE_X-1) begin
                    ox <= ox + 1;
                    cnt_x <= 0;
                end else begin
                    cnt_x <= cnt_x + 1;
                end
            end
            NEXT_LINE: begin
                if (SCALE_Y <= 1 || cnt_y == SCALE_Y-1) begin
                    oy <= oy + 1;
                    cnt_y <= 0;
                    pos <= pos + 1;
                end else begin
                    cnt_y <= cnt_y + 1;
                end
            end
            DONE: done <= 1;
        endcase

        if (rst) begin
            state <= IDLE;
            ox <= 0;
            oy <= 0;
            cnt_x <= 0;
            cnt_y <= 0;
            spr_line <= 0;
            pos <= 0;
            done <= 0;
        end
    end

    // output current pixel colour when drawing
    always_comb begin
        pix = (state == DRAW) ? spr_line[ox] : 0;
    end

    // create status signals
    logic last_pixel, load_line, last_line;
    always_comb begin
        last_pixel = (ox == WIDTH-1 && cnt_x == SCALE_X-1);
        load_line  = (cnt_y == SCALE_Y-1);
        last_line  = (oy == HEIGHT-1 && cnt_y == SCALE_Y-1);
        drawing = (state == DRAW);
    end

    // determine next state
    always_comb begin
        case (state)
            IDLE:       state_next = start ? START : IDLE;
            START:      state_next = AWAIT_DMA;
            AWAIT_DMA:  state_next = dma_avail ? READ_MEM : AWAIT_DMA;
            READ_MEM:   state_next = AWAIT_POS;
            AWAIT_POS:  state_next = (sx == sprx-2) ? DRAW : AWAIT_POS;
            DRAW:       state_next = !last_pixel ? DRAW :
                                    (!last_line ? NEXT_LINE : DONE);
            NEXT_LINE:  state_next = load_line ? AWAIT_DMA : AWAIT_POS;
            DONE:       state_next = IDLE;
            default:    state_next = IDLE;
        endcase
    end
endmodule

F in Space!

We have an animated starfield, we have a font-friendly sprite module, let’s draw an ‘F’ in space.

To avoid confusion, we’re going to use code point to refer to the numerical representation of a character and glyph to refer to the graphicical representation.

Capital F has the code point U+0046. However, our Latin font file has 64 glyphs covering code points U+0020 - U+005F, so we need to subtract 0x20 (32 decimal) from the code point to load the correct glyph.

The new top module combines the starfield design from earlier in this post, with a single sprite: [xc7/top_space_f.sv].

module top_space_f (
    input  wire logic clk_100m,     // 100 MHz clock
    input  wire logic btn_rst_n,    // reset button (active low)
    output      logic vga_hsync,    // horizontal sync
    output      logic vga_vsync,    // vertical sync
    output      logic [3:0] vga_r,  // 4-bit VGA red
    output      logic [3:0] vga_g,  // 4-bit VGA green
    output      logic [3:0] vga_b   // 4-bit VGA blue
    );

    // generate pixel clock
    logic clk_pix;
    logic clk_pix_locked;
    logic rst_pix;
    clock_480p clock_pix_inst (
       .clk_100m,
       .rst(!btn_rst_n),  // reset button is active low
       .clk_pix,
       .clk_pix_5x(),  // not used for VGA output
       .clk_pix_locked
    );
    always_ff @(posedge clk_pix) rst_pix <= !clk_pix_locked;  // wait for clock lock

    // display sync signals and coordinates
    localparam CORDW = 16;
    logic signed [CORDW-1:0] sx, sy;
    logic hsync, vsync;
    logic de, line;
    display_480p #(.CORDW(CORDW)) display_inst (
        .clk_pix,
        .rst_pix,
        .sx,
        .sy,
        .hsync,
        .vsync,
        .de,
        .frame(),
        .line
    );

    // font glyph ROM
    localparam FONT_WIDTH  = 8;   // width in pixels (also ROM width)
    localparam FONT_HEIGHT = 8;   // height in pixels
    localparam FONT_GLYPHS = 64;  // number of glyphs
    localparam F_ROM_DEPTH = FONT_GLYPHS * FONT_HEIGHT;
    localparam FONT_FILE   = "font_unscii_8x8_latin_uc.mem";

    logic [$clog2(F_ROM_DEPTH)-1:0] font_rom_addr;
    logic [FONT_WIDTH-1:0] font_rom_data;  // line of glyph pixels

    rom_sync #(
        .WIDTH(FONT_WIDTH),
        .DEPTH(F_ROM_DEPTH),
        .INIT_F(FONT_FILE)
    ) font_rom (
        .clk(clk_pix),
        .addr(font_rom_addr),
        .data(font_rom_data)
    );

    // sprite
    localparam SPR_SCALE_X = 32;  // enlarge sprite width by this factor
    localparam SPR_SCALE_Y = 32;  // enlarge sprite height by this factor

    // horizontal and vertical screen position of letter
    localparam SPR_X = 192;
    localparam SPR_Y = 112;

    // signal to start sprite drawing
    logic spr_start;
    always_comb spr_start = (line && sy == SPR_Y);

    // subtract 0x20 from code points as font starts at U+0020
    localparam SPR_GLYPH_ADDR = FONT_HEIGHT * 'h26;  // F U+0046

    // font ROM address
    logic [$clog2(FONT_HEIGHT)-1:0] spr_glyph_line;
    logic spr_fdma;  // font ROM DMA slot
    always_comb begin
        font_rom_addr = 0;
        spr_fdma = line;  // load glyph line at start of horizontal blanking
        font_rom_addr = (spr_fdma) ? SPR_GLYPH_ADDR + spr_glyph_line : 0;
    end

    logic spr_pix;  // sprite pixel
    sprite #(
        .WIDTH(FONT_WIDTH),
        .HEIGHT(FONT_HEIGHT),
        .SCALE_X(SPR_SCALE_X),
        .SCALE_Y(SPR_SCALE_Y),
        .LSB(0),
        .CORDW(CORDW),
        .ADDRW($clog2(FONT_HEIGHT))
        ) spr (
        .clk(clk_pix),
        .rst(rst_pix),
        .start(spr_start),
        .dma_avail(spr_fdma),
        .sx,
        .sprx(SPR_X),
        .data_in(font_rom_data),
        .pos(spr_glyph_line),
        .pix(spr_pix),
        .drawing(),
        .done()
    );

    // starfields
    logic sf1_on, sf2_on, sf3_on;
    logic [7:0] sf1_star, sf2_star, sf3_star;

    starfield #(.INC(-1), .SEED(21'h9A9A9)) sf1 (
        .clk(clk_pix),
        .en(1'b1),
        .rst(rst_pix),
        .sf_on(sf1_on),
        .sf_star(sf1_star)
    );

    starfield #(.INC(-2), .SEED(21'hA9A9A)) sf2 (
        .clk(clk_pix),
        .en(1'b1),
        .rst(rst_pix),
        .sf_on(sf2_on),
        .sf_star(sf2_star)
    );

    starfield #(.INC(-4), .MASK(21'h7FF)) sf3 (
        .clk(clk_pix),
        .en(1'b1),
        .rst(rst_pix),
        .sf_on(sf3_on),
        .sf_star(sf3_star)
    );

    // sprite colour & star brightness
    logic [3:0] red_spr, green_spr, blue_spr, starlight;
    always_comb begin
        {red_spr, green_spr, blue_spr} = (spr_pix) ? 12'hFC0 : 12'h000;
        starlight = (sf1_on) ? sf1_star[7:4] :
                    (sf2_on) ? sf2_star[7:4] :
                    (sf3_on) ? sf3_star[7:4] : 4'h0;
    end

    // VGA output
    always_ff @(posedge clk_pix) begin
        vga_hsync <= hsync;
        vga_vsync <= vsync;
        vga_r <= de ? spr_pix ? red_spr   : starlight : 4'h0;
        vga_g <= de ? spr_pix ? green_spr : starlight : 4'h0;
        vga_b <= de ? spr_pix ? blue_spr  : starlight : 4'h0;
    end
endmodule

Hello - こんにちは

Our ‘F’ in space doesn’t take advantage of the full functionality of the sprite module. Let’s update our top module to say “Hello” using five sprites; I’ve done this for English and Japanese:

Try creating your own five-character message. Check the fonts for which characters are available, or create your own font-variant to write in another language.

These modules make extensive use of for loops to avoid duplication. To create multiple instances of a module we need to use generate rather than a normal for loop.

こんにちは

Controlling the Message

To display a custom message we can store a message as code points: greet.mem.

A single two-line message looks like this.

//   FPGA
// AD ASTRA
20 20 46 50 47 41 20 20
41 44 20 41 53 54 52 41

The greeting ROM loads the messages and looks similar to the font ROM:

    // greeting message ROM
    localparam GREET_MSGS   = 32;    // 32 messages
    localparam GREET_LENGTH = 16;    // each containing 16 code points
    localparam G_ROM_WIDTH  = $clog2('h5F);  // highest code point is U+005F
    localparam G_ROM_DEPTH  = GREET_MSGS * GREET_LENGTH;
    localparam GREET_FILE   = "greet.mem";

    logic [$clog2(G_ROM_DEPTH)-1:0] greet_rom_addr;
    logic [G_ROM_WIDTH-1:0] greet_rom_data;  // code point

    rom_sync #(
        .WIDTH(G_ROM_WIDTH),
        .DEPTH(G_ROM_DEPTH),
        .INIT_F(GREET_FILE)
    ) greet_rom (
        .clk(clk_pix),
        .addr(greet_rom_addr),
        .data(greet_rom_data)
    );

To express ourselves better, I’ve expanded the number of sprites to eight, then reused them to form a second line of text. This gives us 16 characters to work with per message.

Now we have our message in memory, the process for calculating the font ROM address is more complex:

  1. Set the greeting ROM address to the chosen message (at start of blanking)
  2. Save the code points of the characters from the ROM (one cycle later)
  3. Use the code points to calculate the glyph address (two cycles later)

We use for loops and offset the times relative to the start of the blanking interval:

    integer i;  // for looping over sprite signals

    // greeting ROM address
    logic [$clog2(G_ROM_DEPTH)-1:0] msg_start;
    always_comb begin
        greet_rom_addr = 0;
        msg_start = greeting * GREET_LENGTH;  // calculate start of message
        for (i = 0; i < SPR_CNT; i = i + 1) begin
            if (sx == SPR_DMA+i)
                greet_rom_addr = (sy < LINE2) ? (msg_start+i) :
                                                (msg_start+i+GREET_LENGTH/2);
        end
    end

    // load code point from greeting ROM
    logic [G_ROM_WIDTH-1:0] spr_cp [SPR_CNT];
    always_ff @(posedge clk_pix) begin
        for (i = 0; i < SPR_CNT; i = i + 1) begin
            if (sx == SPR_DMA+i + 1) spr_cp[i] <= greet_rom_data;  // wait 1
        end
    end

    // font ROM address
    logic [$clog2(F_ROM_DEPTH)-1:0] spr_glyph_addr [SPR_CNT];
    logic [$clog2(FONT_HEIGHT)-1:0] spr_glyph_line [SPR_CNT];
    logic [SPR_CNT-1:0] spr_fdma;  // font ROM DMA slots
    always_comb begin
        font_rom_addr = 0;
        for (i = 0; i < SPR_CNT; i = i + 1) begin
            spr_fdma[i] = (sx == SPR_DMA+i + 2);  // wait 2
            spr_glyph_addr[i] = (spr_cp[i] - CP_START) * FONT_HEIGHT;
            if (spr_fdma[i])
                font_rom_addr = spr_glyph_addr[i] + spr_glyph_line[i];
        end
    end

Greetings Demo v1

Using the greetings logic, I’ve created a demo to greet a few of the open-source FPGA projects we love; apologies to everyone we missed: [xc7/top_greet_v1.sv]

We cycle through the greetings using a frame counter; I’ve chosen 80 frames (1.25 seconds). You can adjust this with the MSG_CHG parameter.

Copperbars

The text feels a bit flat in plain gold: what we need are copper bars! While we don’t have a co-processor (yet), we can create the effect using a simple counter. I’ve gone for a sky and earth colour scheme, but you can easily change the colours to your own taste:

    // font colours
    localparam COLR_A   = 'h125;  // initial colour A
    localparam COLR_B   = 'h421;  // initial colour B
    localparam SLIN_1A  = 'd150;  // 1st line of colour A
    localparam SLIN_1B  = 'd178;  // 1st line of colour B
    localparam SLIN_2A  = 'd250;  // 2nd line of colour A
    localparam SLIN_2B  = 'd278;  // 2nd line of colour B
    localparam LINE_INC = 3;      // lines of each colour

    logic [11:0] font_colr;  // 12 bit colour (4-bit per channel)
    logic [$clog2(LINE_INC)-1:0] cnt_line;
    always_ff @(posedge clk_pix) begin
        if (line) begin
            if (sy == SLIN_1A || sy == SLIN_2A) begin
                cnt_line <= 0;
                font_colr <= COLR_A;
            end else if (sy == SLIN_1B || sy == SLIN_2B) begin
                cnt_line <= 0;
                font_colr <= COLR_B;
            end else begin
                cnt_line <= cnt_line + 1;
                if (cnt_line == LINE_INC-1) begin
                    cnt_line <= 0;
                    font_colr <= font_colr + 'h111;
                end
            end
        end
    end

Our final greeting design: [xc7/top_greet.sv]

FPGA Ad Astra

What’s Next?

Check out my other FPGA demos or the FPGA graphics tutorials. The Framebuffers post uses a LFSR to fizzle fade a bitmap image.

Get in touch on Mastodon, Bluesky, or X. If you enjoy my work, please sponsor me. 🙏