Project F

Sine Scroller

Published · Updated

This FPGA demo effect renders a horizontally scrolling message along a sine wave. I created this effect with benjamin.computer for All You Need, a Chapterhouse prod released at Revision 2022.

The design was originally for a custom Artix-7 dev board, but this version runs on the Digilent Arty A7 or as a Verilator/SDL simulation on your computer.

Share your thoughts with @WillFlux on Mastodon or Twitter. If you like what I do, sponsor me. 🙏

What is a Sine Scroller?

A sine scroller is a classic demoscene effect where the scrolling text moves up and down in a sine wave. You can see an excellent example in the SkidRow Lemmings Cracktro (YouTube).

Our implementation is less sophisticated, only moving each letter up and down rather than distorting each character, but the effect does at least run at a smooth 60 FPS.

FPGA Demo

Building the Demo

Find the Verilog source and build instructions in the projf-explore git repo:
https://github.com/projf/projf-explore/tree/main/demos/sinescroll/

New to FPGA graphics design? Start with Beginning FPGA Graphics.

Demo Structure

I’ve written a separate post on the workings of the FPGA Sine Lookup Table.

Resources

The font is based on 32X32-FL.png from Ian Hanschen’s demoscene collection. I removed the colours and tweaked a few pixels. The final font bitmap was converted to $readmemh format with img2fmem.

32x32 pixel outline font

Our font includes 64 characters (codepoints U+0020 to U+005F), so an 8-bit value suffices for each character. The first part of the message is “ALL YOU NEED”, which is stored as follows (20 represents space):

41 4C 4C 20 59 4F 55 20 4E 45 45 44

Scroller Rendering

A finite state machine (but of course) drives the renderer:

  • Load character code point from greet.mem stored in ROM
  • Calculate character glyph position (includes sine table lookup)
  • Check if glyph is visible; if not jump to next character
  • Draw glyph with draw_char module loaded with our font
  • Once all glyphs are drawn, update message position offset for next frame

cx_offs is the horizontal position where message drawing begins; we start at the right side of the framebuffer and subtract GREET_SPD each frame to scroll left. You can adjust GREET_SPD to control the speed of the scroll and SIN_SHIFT to set the scale of the sine wave. If you change the message, set GREET_LEN to the new message length.

module render_sinescroll #(
    parameter CORDW=16,       // signed coordinate width
    parameter GREET_FILE="",  // greet text ROM .mem file
    parameter FONT_FILE="",   // font glyph ROM .mem file
    parameter SIN_FILE="",    // sine table ROM .mem file
    parameter SIN_SHIFT=3,    // right-shift sine values
    parameter FB_WIDTH=320,   // framebuffer width in pixels
    parameter FB_HEIGHT=180   // framebuffer height in pixels
    ) (
    input  wire logic clk,                      // clock
    input  wire logic rst,                      // reset
    input  wire logic oe,                       // output enable
    input  wire logic start,                    // start control
    output      logic signed [CORDW-1:0] x, y,  // drawing position
    output      logic pix,                      // draw pixel at this position?
    output      logic drawing,                  // drawing in progress
    output      logic done                      // drawing is complete
    );

    // sine table
    localparam SIN_DEPTH=64;  // entires in sine ROM 0°-90°
    localparam SIN_WIDTH=8;   // width of sine ROM data
    localparam SIN_ADDRW=$clog2(4*SIN_DEPTH);   // full table -180° to +180°

    logic [SIN_ADDRW-1:0] sin_id, sin_offs;
    logic signed [CORDW-1:0] sin_data;  // sign extend data to match coords
    sine_table #(
        .ROM_DEPTH(SIN_DEPTH),
        .ROM_WIDTH(SIN_WIDTH),
        .ROM_FILE(SIN_FILE)
    ) sine_table_inst (
        .id(sin_id + sin_offs),
        .data(sin_data)
    );

    // greeting message ROM
    localparam GREET_MSGS  =   1;  // 1 message
    localparam GREET_LEN   =  71;  // number of code points
    localparam G_ROM_WIDTH =   8;  // highest code point is U+00FF
    localparam G_ROM_DEPTH = GREET_MSGS * GREET_LEN;
    localparam GREET_SPD   =   3;  // speed in pixels/frame
    
    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,
        .addr(greet_rom_addr),
        .data(greet_rom_data)
    );

    // Outline 32x32 font
    localparam GLYPH_WIDTH  =  32;
    localparam GLYPH_SPACE  =   2;  // horizontal spacing
    localparam GLYPH_HEIGHT =  32;
    localparam GLYPH_COUNT  =  64;
    localparam GLYPH_OFFSET =  32;  // starts at U+0020
    localparam FONT_LSB = 0;

    // draw chars in framebuffer
    localparam CHAR_NUM = GREET_LEN;  // length of message in characters
    logic [$clog2(CHAR_NUM):0] cnt_char;  // message char counter
    logic signed [CORDW-1:0] cx, cy;  // chars coords
    logic signed [CORDW-1:0] cx_offs;  // horizontal offset for scrolling
    logic [7:0] ucp;  // Unicode code point (0-255 only)
    logic draw_start, draw_done;  // drawing signals

    // draw state machine
    enum {IDLE, INIT, MEM_WAIT, LOAD_CHAR, CLIP, DRAW, DONE} state;
    always_ff @(posedge clk) begin
        draw_start <= 0;
        case (state)
            INIT: begin  // register coordinates and colour
                state <= MEM_WAIT;
                greet_rom_addr <= cnt_char;  // max address is CHAR_NUM-1 
                sin_id <= cnt_char * 16;
            end
            MEM_WAIT: begin
                state <= LOAD_CHAR;
            end
            LOAD_CHAR: begin
                ucp <= greet_rom_data;
                cx <= cx_offs + cnt_char * (GLYPH_WIDTH + GLYPH_SPACE);
                cy <= FB_HEIGHT/2-GLYPH_HEIGHT/2 + (sin_data >>> SIN_SHIFT);  // centre
                state <= CLIP;
            end
            CLIP: begin  // only render glyphs in the framebuffer area
                if (cx > -(GLYPH_WIDTH + GLYPH_SPACE) && cx < FB_WIDTH) begin
                    state <= DRAW;
                    draw_start <= 1;
                    // $display("  DRAW: cnt_char: %d, x=%d, y=%d", cnt_char, cx, cy);
                end else begin
                    if (cnt_char == CHAR_NUM-1) begin
                        state <= DONE;
                    end else begin
                        state <= INIT;
                        cnt_char <= cnt_char + 1;
                    end
                end
            end
            DRAW: begin
                if (draw_done) begin
                    if (cnt_char == CHAR_NUM-1) begin
                        state <= DONE;
                    end else begin
                        state <= INIT;
                        cnt_char <= cnt_char + 1;
                    end
                end
            end
            DONE: state <= IDLE;
            default: if (start) begin  // IDLE
                state <= INIT;
                cnt_char <= 0;
                sin_offs <= sin_offs + 1;
                // if final char has been drawn off the screen, restart loop
                cx_offs <= (cx < -GLYPH_WIDTH) ? FB_WIDTH : cx_offs - GREET_SPD;
                // $display("START: cx_offs: %d", cx_offs);
            end
        endcase
        if (rst) begin
            state <= INIT;
            cnt_char <= 0;
            sin_offs <= 0;
            cx_offs <= FB_WIDTH;
        end
    end

    draw_char #(
        .CORDW(CORDW),
        .WIDTH(GLYPH_WIDTH),
        .HEIGHT(GLYPH_HEIGHT),
        .COUNT(GLYPH_COUNT),
        .OFFSET(GLYPH_OFFSET),
        .FONT_FILE(FONT_FILE),
        .LSB(FONT_LSB)
        ) draw_char_inst (
        .clk,
        .rst,
        .oe,
        .start(draw_start),
        .ucp,
        .cx,
        .cy,
        .x,
        .y,
        .pix,
        .drawing,
        .busy(),
        .done(draw_done)
    );

    // done for this module
    always_comb done = (state == DONE);
endmodule

This module isn’t the tightest design, being built (in a hurry) for a demo prod. For example, we consider (but don’t draw) every character in the greeting every frame, even though the clipping check could reduce this significantly.

What’s Next?

If you enjoyed this post, please sponsor me. Sponsors help me create more FPGA and RISC-V projects for everyone, and they get early access to blog posts and source code. 🙏

Check out my demos, FPGA graphics tutorial, and guide to FPGA sine lookup tables.