C] 콘솔 프로그램 플리커 현상 개선하기 (더블 버퍼링 & 오프스크린 버퍼)
콘솔 화면을 주기적으로 갱신하는 프로그램을 개발 시 화면이 깜빡이는 플리커(flicker) 현상이 발생할 수 있다. 이번 글에서는 이런 플리커 현상을 개선하기 위한 전략을 알아보자.
먼저 대략 60fps로 7세그먼트 형식의 디지털시계를 출력하는 C언어 프로그램을 작성해보았다. 프로그램을 실행시키면 아래 이미지 처럼 화면이 깜빡이면서 출력되는 것을 볼 수 있다.
플리커 현상의 원인은?
플리커 현상은 화면 갱신 과정에서 발생하는 중간 상태가 사용자에게 노출되면서 발생한다. 아래 코드를 보면 system("cls")를 사용해 화면을 완전히 지운 후, 초기화된 화면에 새로운 데이터를 출력하고 있다. 이 과정이 빠르게 반복되면서 빈 화면과 새 데이터를 그리는 중간 상태가 짧은 시간 동안 화면에 보여지게 되고 사용자는 이를 깜빡임으로 인식하는 것이다.
아래 코드는 플리커 현상을 발생시키는 코드이다.
#include <stdio.h>
#include <time.h>
#include <Windows.h>
#include <string.h>
#define WIDTH 8
#define HEIGHT 7
// 7 segment 숫자
const char* SEGMENTS[10][HEIGHT] = {
{"■■■■■", "■ ■", "■ ■", "■ ■", "■ ■", "■ ■", "■■■■■"}, // 0
{" ■ ", " ■■ ", " ■ ", " ■ ", " ■ ", " ■ ", " ■■■ "}, // 1
{"■■■■■", " ■", " ■", "■■■■■", "■ ", "■ ", "■■■■■"}, // 2
{"■■■■■", " ■", " ■", "■■■■■", " ■", " ■", "■■■■■"}, // 3
{"■ ■", "■ ■", "■ ■", "■■■■■", " ■", " ■", " ■"}, // 4
{"■■■■■", "■ ", "■ ", "■■■■■", " ■", " ■", "■■■■■"}, // 5
{"■■■■■", "■ ", "■ ", "■■■■■", "■ ■", "■ ■", "■■■■■"}, // 6
{"■■■■■", " ■", " ■", " ■ ", " ■ ", " ■ ", " ■ "}, // 7
{"■■■■■", "■ ■", "■ ■", "■■■■■", "■ ■", "■ ■", "■■■■■"}, // 8
{"■■■■■", "■ ■", "■ ■", "■■■■■", " ■", " ■", "■■■■■"} // 9
};
// 콜론
const char* COLON[HEIGHT] = { " ", " ■ ", " ■ ", " ", " ■ ", " ■ ", " " };
// 화면 지우는 함수
void clear_screen() {
system("cls");
}
// 현재 시간 가져오는 함수
void get_current_time(int* hour, int* minute, int* second) {
time_t t = time(NULL);
struct tm* tm_info = localtime(&t);
*hour = tm_info->tm_hour;
*minute = tm_info->tm_min;
*second = tm_info->tm_sec;
}
// x, y 위치에서 세븐 세그먼트를 그리는 함수
void draw_segment(const char* segment[], int x, int y) {
for (int i = 0; i < HEIGHT; i++) {
COORD coord = { x, y + i };
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord);
printf("%s", segment[i]);
}
}
// 시간을 출력하는 함수
void draw_time(int hour, int minute, int second) {
char buffer[9];
sprintf(buffer, "%02d:%02d:%02d", hour, minute, second); // HH:MM:SS 형식
for (int i = 0; i < 8; i++) {
if (buffer[i] == ':') {
draw_segment(COLON, 10 + (i * WIDTH), 5); // 콜론 출력
}
else {
int digit = buffer[i] - '0';
draw_segment(SEGMENTS[digit], 10 + (i * WIDTH), 5); // 숫자 출력
}
}
}
int main() {
int hour, minitue, second;
while (1) {
// 현재 시간 가져오기
get_current_time(&hour, &minitue, &second);
// 화면 초기화
clear_screen();
// 시간 그리기
draw_time(hour, minitue, second);
// 16ms 대기
Sleep(16);
}
return 0;
}
다음은 더블 버퍼링 기법을 사용해서 플리커 현상을 개선한 프로그램 화면이다. 보시다시피 깜빡임이 전혀 느껴지지 않는다.
Double Buffering
더블 버퍼링은 두 개의 화면 버퍼(출력 가능한 버퍼)를 번갈아 사용하는 방식이다. 새 데이터를 작성할 때는 비활성 버퍼(Back Buffer)에 작업을 수행하며, 작업이 완료되면 활성 버퍼(Active Buffer)와 비활성 버퍼를 교체(Flipping)한다. 이렇게 교체된 활성 버퍼가 사용자에게 출력되므로, 화면 갱신 중 발생하는 중간 상태가 사용자에게 노출되지 않고, 항상 완성된 데이터만 화면에 표시되기 때문에 깜빡임 없이 부드러운 화면 전환이 가능하다.
아래 코드가 더블 버퍼링을 사용해서 플리커 현상을 개선한 코드이다.
#include <stdio.h>
#include <Windows.h>
#include <time.h>
#define WIDTH 8
#define HEIGHT 7
// 더블 버퍼링 구조체
typedef struct {
HANDLE buffers[2]; // 두 개의 콘솔 버퍼 핸들
int current; // 현재 활성 버퍼의 인덱스
} DoubleBuffer;
// 세븐 세그먼트 숫자 데이터
const char* SEGMENTS[10][HEIGHT] = {
{"■■■■■", "■ ■", "■ ■", "■ ■", "■ ■", "■ ■", "■■■■■"},
{" ■ ", " ■■ ", " ■ ", " ■ ", " ■ ", " ■ ", " ■■■ "},
{"■■■■■", " ■", " ■", "■■■■■", "■ ", "■ ", "■■■■■"},
{"■■■■■", " ■", " ■", "■■■■■", " ■", " ■", "■■■■■"},
{"■ ■", "■ ■", "■ ■", "■■■■■", " ■", " ■", " ■"},
{"■■■■■", "■ ", "■ ", "■■■■■", " ■", " ■", "■■■■■"},
{"■■■■■", "■ ", "■ ", "■■■■■", "■ ■", "■ ■", "■■■■■"},
{"■■■■■", " ■", " ■", " ■ ", " ■ ", " ■ ", " ■ "},
{"■■■■■", "■ ■", "■ ■", "■■■■■", "■ ■", "■ ■", "■■■■■"},
{"■■■■■", "■ ■", "■ ■", "■■■■■", " ■", " ■", "■■■■■"}
};
// 콜론 데이터
const char* COLON[HEIGHT] = { " ", " ■ ", " ■ ", " ", " ■ ", " ■ ", " " };
// 현재 시간 가져오는 함수
void get_current_time(int* hour, int* minute, int* second) {
time_t t = time(NULL);
struct tm* tm_info = localtime(&t);
*hour = tm_info->tm_hour;
*minute = tm_info->tm_min;
*second = tm_info->tm_sec;
}
// 더블 버퍼링 초기화
void initialize_double_buffer(DoubleBuffer* db) {
db->buffers[0] = CreateConsoleScreenBuffer(GENERIC_READ | GENERIC_WRITE, 0, NULL, CONSOLE_TEXTMODE_BUFFER, NULL);
db->buffers[1] = CreateConsoleScreenBuffer(GENERIC_READ | GENERIC_WRITE, 0, NULL, CONSOLE_TEXTMODE_BUFFER, NULL);
db->current = 0;
if (db->buffers[0] == INVALID_HANDLE_VALUE || db->buffers[1] == INVALID_HANDLE_VALUE) {
fprintf(stderr, "Failed to create console buffers.\n");
exit(1);
}
// 커서 숨기기
CONSOLE_CURSOR_INFO cursorInfo = { 1, FALSE };
SetConsoleCursorInfo(db->buffers[0], &cursorInfo);
SetConsoleCursorInfo(db->buffers[1], &cursorInfo);
}
// 더블 버퍼링 해제
void release_double_buffer(DoubleBuffer* db) {
for (int i = 0; i < 2; i++) {
if (db->buffers[i] != INVALID_HANDLE_VALUE) {
CloseHandle(db->buffers[i]);
}
}
}
// 활성 버퍼 가져오기
HANDLE get_active_buffer(DoubleBuffer* db) {
return db->buffers[db->current];
}
// 비활성 버퍼 가져오기
HANDLE get_inactive_buffer(DoubleBuffer* db) {
return db->buffers[1 - db->current];
}
// 버퍼 전환
void flip_buffers(DoubleBuffer* db) {
db->current = 1 - db->current;
SetConsoleActiveScreenBuffer(db->buffers[db->current]);
}
// 버퍼 초기화
void clear_buffer(HANDLE buffer) {
CONSOLE_SCREEN_BUFFER_INFO csbi;
GetConsoleScreenBufferInfo(buffer, &csbi);
DWORD written;
FillConsoleOutputCharacter(buffer, ' ', csbi.dwSize.X * csbi.dwSize.Y, (COORD) { 0, 0 }, & written);
SetConsoleCursorPosition(buffer, (COORD) { 0, 0 });
}
// 세그먼트 그리기
void draw_segment(const char* segment[], HANDLE buffer, COORD start) {
DWORD written;
for (int i = 0; i < HEIGHT; i++) {
COORD coord = { start.X, start.Y + i };
WriteConsoleOutputCharacter(buffer, segment[i], (DWORD)strlen(segment[i]), coord, &written);
}
}
// 시간 그리기
void draw_to_buffer(HANDLE buffer, int hour, int minute, int second) {
char time_str[9];
sprintf(time_str, "%02d:%02d:%02d", hour, minute, second);
for (int i = 0; i < 8; i++) {
COORD start = { 10 + (i * WIDTH), 5 };
if (time_str[i] == ':') {
draw_segment(COLON, buffer, start);
}
else {
int digit = time_str[i] - '0';
draw_segment(SEGMENTS[digit], buffer, start);
}
}
}
int main() {
DoubleBuffer db;
initialize_double_buffer(&db);
int hour, minute, second;
while (1) {
// 현재 시간 가져오기
get_current_time(&hour, &minute, &second);
// 비활성 버퍼 초기화 및 그리기
HANDLE inactive = get_inactive_buffer(&db);
clear_buffer(inactive);
draw_to_buffer(inactive, hour, minute, second);
// 버퍼 전환
flip_buffers(&db);
// 16ms 대기
Sleep(16);
}
release_double_buffer(&db);
return 0;
}
아래는 더블 버퍼링이 아니라 메모리 상의 준비용 버퍼에 데이터를 작성한 후 완성된 내용을 한 번에 콘솔로 출력하는 방식으로 플리커 현상을 개선해본 것이다. 역시 깜빡임이 전혀 느껴지지 않는다.
One Off-screen Buffering
단일 오프스크린 버퍼를 사용하는 방법으로 화면 버퍼(출력 가능한 버퍼)는 하나만 활성화되어 있고, 준비용 메모리 버퍼에 데이터를 먼저 작성한 뒤, 이를 화면 버퍼로 복사하는 방식이다. 이 방식 또한 오프스크린 버퍼에서 작업이 완료된 데이터만 화면에 표시되므로 플리커 현상이 개선된다.
아래 코드가 단일 오프 스크린 버퍼를 사용해서 플리커 현상을 개선한 코드이다.
참고로 아래 코드에선 메모리 준비 버퍼로 CHAR_INFO 구조체를 사용하고 있는데 CHAR_INFO는 텍스트 문자와 속성을 저장할 수 있는 구조체로 유니코드와 아스키 두 가지 문자 형식을 둘 다 지원한다.
위 코드에서 7세그먼트 출력시 사용된 ■ 문자는 유니코드 문자이므로 이를 출력하려면 유니코드 문자 집합을 사용하고 출력 데이터를 wchar_t 형식으로 준비한 뒤 CHAR_INFO의 UnicodeChar 필드에 할당하면 ■ 문자를 사용할 수 있다. 다만, 아래 코드에서는 간단히 작성하기 위해 ■ 대신 #로 대체하였다.
#include <stdio.h>
#include <Windows.h>
#include <time.h>
#define WIDTH 8 // 글자간격 포함 너비
#define HEIGHT 7
// 7 segment 숫자 데이터
const char* SEGMENTS[10][HEIGHT] = {
{"#####", "# #", "# #", "# #", "# #", "# #", "#####"}, // 0
{" #", " #", " #", " #", " #", " #", " #"}, // 1
{"#####", " #", " #", "#####", "# ", "# ", "#####"}, // 2
{"#####", " #", " #", "#####", " #", " #", "#####"}, // 3
{"# #", "# #", "# #", "#####", " #", " #", " #"}, // 4
{"#####", "# ", "# ", "#####", " #", " #", "#####"}, // 5
{"#####", "# ", "# ", "#####", "# #", "# #", "#####"}, // 6
{"#####", " #", " #", " #", " #", " #", " #"}, // 7
{"#####", "# #", "# #", "#####", "# #", "# #", "#####"}, // 8
{"#####", "# #", "# #", "#####", " #", " #", "#####"} // 9
};
// 콜론 데이터
const char* COLON[HEIGHT] = { " ", " # ", " ", " ", " # ", " ", " " };
// CHAR_INFO 기반 메모리 버퍼
CHAR_INFO screenBuffer[80 * 25];
COORD bufferSize = { 80, 25 };
COORD bufferCoord = { 0, 0 };
SMALL_RECT writeRegion = { 0, 0, 79, 24 };
// 현재 시간 가져오기
void get_current_time(int* hour, int* minute, int* second) {
time_t t = time(NULL);
struct tm* tm_info = localtime(&t);
*hour = tm_info->tm_hour;
*minute = tm_info->tm_min;
*second = tm_info->tm_sec;
}
// 화면 초기화
void clear_screen() {
for (int i = 0; i < 80 * 25; i++) {
screenBuffer[i].Char.AsciiChar = ' ';
screenBuffer[i].Attributes = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE;
}
}
// 세그먼트 그리기
void draw_segment(const char* segment[], int x, int y) {
for (int i = 0; i < HEIGHT; i++) {
int pos = (y + i) * 80 + x; // CHAR_INFO 배열의 1차원 인덱스 계산
for (int j = 0; j < WIDTH; j++) {
if (segment[i][j] != ' ') { // 비어 있지 않은 경우만 그리기
screenBuffer[pos + j].Char.AsciiChar = segment[i][j];
screenBuffer[pos + j].Attributes = FOREGROUND_RED | FOREGROUND_GREEN | FOREGROUND_BLUE;
}
}
}
}
// 시간 그리기
void draw_time(int hour, int minute, int second) {
char buffer[9];
sprintf(buffer, "%02d:%02d:%02d", hour, minute, second);
for (int i = 0; i < 8; i++) {
int x = 10 + (i * WIDTH); // 글자 간격 추가
int y = 5;
if (buffer[i] == ':') {
draw_segment(COLON, x, y);
}
else {
int digit = buffer[i] - '0';
draw_segment(SEGMENTS[digit], x, y);
}
}
}
// 메모리 버퍼를 콘솔로 출력
void render_screen(HANDLE hConsole) {
WriteConsoleOutput(hConsole, screenBuffer, bufferSize, bufferCoord, &writeRegion);
}
int main() {
// 콘솔 핸들 가져오기
HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
// 콘솔 커서 숨기기
CONSOLE_CURSOR_INFO cursorInfo = { 1, FALSE };
SetConsoleCursorInfo(hConsole, &cursorInfo);
int hour, minute, second;
while (1) {
// 현재 시간 가져오기
get_current_time(&hour, &minute, &second);
// 메모리 버퍼 초기화
clear_screen();
// 메모리 버퍼에 시간 그리기
draw_time(hour, minute, second);
// 콘솔로 출력
render_screen(hConsole);
// 16ms 대기
Sleep(16);
}
return 0;
}