Sine Scroller
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.
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
- Top module with display interfaces
- Arty (XC7): xc7/top_sinescroll.sv
- Verilator/SDL: sim/top_sinescroll.sv
- Scroller render module: render_sinescroll.sv (see below)
- Project F library modules
- Resources
- Outline font: fonts/outline-font-32x32.mem
- Greetings text: text/greet.mem
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.
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.