Eliminating Screen Tearing! FPGA Video Buffer Design (Part 1): Writing a Ping-Pong Controller from Scratch

0. Visual ChinaEliminating Screen Tearing! FPGA Video Buffer Design (Part 1): Writing a Ping-Pong Controller from ScratchVisual China Contracted Photographer Horizon – Little Train1. Introduction: Why Use Buffers?Many FPGA beginners think that when processing video, the most straightforward idea is: the data from the camera can be directly sent to the HDMI display module, right?This “direct connection” method only works under an extreme coincidence: the output clock of the camera and the display clock of the HDMI must be completely locked and in phase. However, in the real world:

  • The camera’s clock may be 24MHz.
  • The HDMI display clock may be 148.5MHz.

  • The camera outputs 30 frames of data per second, while the display refreshes 60 times per second.

If you force a direct connection, the consequence isscreen tearing: the upper half of the screen displays the Nth frame, while the lower half suddenly changes to the N+1th frame. It looks very ugly, like forcibly cutting and splicing two photos together.To solve this problem, we need a “reservoir”, which is—frame buffer. The most basic and classic buffer architecture isdouble buffering (Ping-Pong Buffer).2. What is “Ping-Pong Operation”?Ping-Pong means “back and forth”. We allocate two memory spaces in DDR3/DDR4, called Buffer A and Buffer B.

  • First Stage:

    • ✍️ Writer is writing data into Buffer A.

    • 👀 Reader is reading data from Buffer B.

    • (At this time, reading and writing do not interfere with each other)

  • Switching Moment:

    • When the writer has filled A, and the reader has also finished reading B.

    • Swap!

  • Second Stage:

    • ✍️ Writer changes to write into Buffer B.

    • 👀 Reader changes to read from Buffer A.

As long as this rhythm continues, the display will always read a complete image, and screen tearing will disappear.

3. Verilog Code Implementation

// double_buffer.vmodule double_buffer #(     parameter ADDR_WIDTH    = 2    ,parameter BUFFER_A_ADDR = 2'b01    ,parameter BUFFER_B_ADDR = 2'b10)(     input                        clk_i    ,input                        rstn_i        // Control signals    ,input                        frame_write_done    ,input                        frame_read_done    // Status outputs    ,output reg [ADDR_WIDTH-1:0]  current_write_addr    ,output reg [ADDR_WIDTH-1:0]  current_read_addr        // Status indication    ,output reg                   frame_available // Indicates whether a new frame is ready to switch);    // Buffer index definitions    localparam IDX_A = 1'b0;    localparam IDX_B = 1'b1;    // Internal pointers    reg     wr_idx; // 0 writes A, 1 writes B    reg     rd_idx; // 0 reads A, 1 reads B        // Core logic: new frame ready flag    // When a frame is written, set this flag to 1; only when read is complete and the flag is 1, will a swap occur    reg     new_frame_ready;     // Address lookup function    function [ADDR_WIDTH-1:0] get_addr(input idx);        case(idx)            IDX_A: get_addr = BUFFER_A_ADDR;            IDX_B: get_addr = BUFFER_B_ADDR;        endcase    endfunction    always @(posedge clk_i or negedge rstn_i) begin        if (!rstn_i) begin            wr_idx          <= IDX_A; // Default write A            rd_idx          <= IDX_B; // Default read B            new_frame_ready <= 1'b0;        end else begin            // -------------------------------------------------------            // Write logic            // -------------------------------------------------------            if (frame_write_done) begin                // Finished writing the current Buffer (Back Buffer)                // Mark that new data is ready for swapping                new_frame_ready <= 1'b1;                                // Note: In double buffering, the Writer cannot decide to switch Buffers by itself.                // If the Writer is too fast, it must stay on the current wr_idx to continue writing (overwriting old data/dropping frames),                // or external logic must pause the Writer. Here we keep wr_idx unchanged, waiting for the Reader to allow the swap.            end            // -------------------------------------------------------            // Read logic (the only moment a swap occurs)            // -------------------------------------------------------            if (frame_read_done) begin                if (new_frame_ready) begin                    // Only when: read is complete AND the background has written a new frame                    // can a Ping-Pong swap occur                    rd_idx          <= ~rd_idx; // Read pointer flip                    wr_idx          <= ~wr_idx; // Write pointer flip (Writer can finally go to the other side)                    new_frame_ready <= 1'b0;    // Consumed the new frame, clear the flag                end                // Otherwise: Reader continues to read the current rd_idx (repeat frame/Hold)            end        end    end    // Output logic    always @(*) begin        current_write_addr = get_addr(wr_idx);        current_read_addr  = get_addr(rd_idx);        frame_available    = new_frame_ready;    endendmodule

4. Simulation Testing

`timescale 1ns / 1psmodule tb_double_buffer;    parameter ADDR_WIDTH = 2;    parameter BUFFER_A_ADDR = 2'b01;    parameter BUFFER_B_ADDR = 2'b10;    reg clk, rstn;    reg frame_write_done, frame_read_done;    wire [ADDR_WIDTH-1:0] current_write_addr, current_read_addr;    wire frame_available;    // Instantiate    double_buffer #(        .ADDR_WIDTH(ADDR_WIDTH), .BUFFER_A_ADDR(BUFFER_A_ADDR), .BUFFER_B_ADDR(BUFFER_B_ADDR)    ) dut (        .clk_i(clk), .rstn_i(rstn),        .frame_write_done(frame_write_done), .frame_read_done(frame_read_done),        .current_write_addr(current_write_addr), .current_read_addr(current_read_addr),        .frame_available(frame_available)    );    // Clock    initial begin clk = 0; forever #5 clk = ~clk; end    // Helper print    task print_status(input [8*20:1] msg);        $display("Time %0t | %s | WR: %s | RD: %s | Ready: %b",                  $time, msg,                  (current_write_addr==BUFFER_A_ADDR)?"A":"B",                 (current_read_addr==BUFFER_A_ADDR)?"A":"B",                 frame_available);    endtask    task tick_write; begin @(posedge clk); frame_write_done <= 1; @(posedge clk); frame_write_done <= 0; #1; end endtask    task tick_read;  begin @(posedge clk); frame_read_done <= 1; @(posedge clk); frame_read_done <= 0; #1; end endtask    initial begin        rstn = 0; frame_write_done = 0; frame_read_done = 0;        #20; rstn = 1; #10;        print_status("INIT      ");        // 1. Normal alternation        $display("\n--- Case 1: Normal Ping-Pong ---");        tick_write(); // Write A complete        print_status("Wr Done A "); // WR is still A, Waiting for read                tick_read();  // Read B complete -> Swap occurs        print_status("Rd Done B "); // Now WR:B, RD:A        // 2. Writing too fast (Writer Blocked)        $display("\n--- Case 2: Writer too fast (Stall/Overwrite) ---");        // Current state: WR writes B, RD reads A        tick_write(); // Write B complete        print_status("Wr Done B1"); // Ready=1, but cannot switch, because RD is still on A                tick_write(); // Wrote B again (drop frame/overwrite)        print_status("Wr Done B2"); // Pointer still hasn't moved! If it were triple buffering, the Writer would have already gone to C                tick_read();  // RD finally finished reading A        print_status("Rd Done A "); // Swap occurs: WR:A, RD:B        // 3. Reading too fast (Reader Starvation)        $display("\n--- Case 3: Reader too fast (Repeat) ---");        // Current state: WR writes A, RD reads B        tick_read();  // Finished reading B, but Writer hasn't finished writing A        print_status("Rd Done B1"); // No swap, repeat read B                tick_read();         print_status("Rd Done B2"); // Still reading B        tick_write(); // Writer finally finished writing A        print_status("Wr Done A "); // Ready=1                tick_read();  // Next read complete        print_status("Rd Done B3"); // Finally swapped        #50 $stop;    endendmodule

Three Scenarios:

  1. Read and write speeds are equal.
  2. Write speed is greater than read speed.
  3. Read speed is greater than write speed.

Eliminating Screen Tearing! FPGA Video Buffer Design (Part 1): Writing a Ping-Pong Controller from Scratch5. Little Secrets in the Code:new_frame_ready

You might ask, why not directly wr_idx <= ~wr_idx? Why use new_frame_ready?

This is to preventreading empty data.

If the reading speed is faster than the writing speed (for example, 60Hz read, 30Hz write).

  1. Reader finishes reading Buffer B.

  2. At this time, the Writer is still slowly writing Buffer A (only half written).

  3. If a swap occurs at this time, the Reader will switch to read Buffer A.It will be a disaster, reading half-written data, causing screen tearing again.

With new_frame_ready, the Reader will obediently read Buffer B again (displaying a still image), until the Writer finishes writing A and raises a sign saying “I am ready”.

6. The Pitfall of Double Buffering

Does it seem perfect? However, in high-speed systems, Ping-Pong has a fatal weakness:strong coupling (Blocking).

Imagine:

  • Writer is writing very fast.

  • It finishes writing A and wants to write B.

  • But!Reader is still slowly reading B.

  • The Writer has nowhere to go! It cannot overwrite B, or the Reader will read incorrectly.

As a result: the Writer is forced to stop and wait (Stall).

For devices like cameras that cannot pause, this meansdata loss. For high-speed acquisition cards, this means FIFO overflow.

Is there a solution that allows the Writer to never wait for the Reader, to write whenever it wants, to fly whenever it wants?

Please stay tuned for the next article—The Magic of Triple Buffering.

Leave a Comment