更新控制台而不闪烁 - c++

25

我正在尝试制作一个控制台的横向卷轴射击游戏,我知道这不是它的理想媒介,但我为自己设定了一个小挑战。

问题在于,每当更新帧时,整个控制台都会闪烁。有没有什么方法可以解决这个问题?

我使用了一个数组来存储所有必要的字符以进行输出,下面是我的updateFrame函数。是的,我知道system("cls")很懒,但除非它是问题的原因,否则我对此并不在意。

void updateFrame()
{
system("cls");
updateBattleField();
std::this_thread::sleep_for(std::chrono::milliseconds(33));
for (int y = 0; y < MAX_Y; y++)
{
    for (int x = 0; x < MAX_X; x++)
    {
        std::cout << battleField[x][y];
    }
    std::cout << std::endl;
}
}

这里有重复的内容。如果没有其他问题,你应该在打印完信息后停止线程,而不是在屏幕被清除后停止,但我猜在Windows上仍然会看到闪烁。 - LogicStuff
2
你应该尝试使用ncurses,它允许在shell中做任何你想做的事情。 - Pierre Emmanuel Lallemant
@LogicStuff:无论是否重复,这是我很久以来一直想回答的问题,因为我自己也花了很长时间摆弄类似的东西。有方法可以避免闪烁。 - Cameron
4个回答

54

啊,这让我回想起好旧的日子。我在高中时也做过类似的事情 :-)

你将遇到性能问题。控制台 I/O,在 Windows 上特别慢。非常、非常慢(有时甚至比写入磁盘还要慢)。实际上,你会很快惊奇于其他工作对游戏循环延迟的影响如此之小,因为 I/O 倾向于支配其他所有操作。所以黄金法则就是尽可能地减少 I/O 操作。

首先,建议放弃 system("cls") 并改用调用实际的 Win32 控制台子系统函数来代替 clsdocs):

#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>

void cls()
{
    // Get the Win32 handle representing standard output.
    // This generally only has to be done once, so we make it static.
    static const HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);

    CONSOLE_SCREEN_BUFFER_INFO csbi;
    COORD topLeft = { 0, 0 };

    // std::cout uses a buffer to batch writes to the underlying console.
    // We need to flush that to the console because we're circumventing
    // std::cout entirely; after we clear the console, we don't want
    // stale buffered text to randomly be written out.
    std::cout.flush();

    // Figure out the current width and height of the console window
    if (!GetConsoleScreenBufferInfo(hOut, &csbi)) {
        // TODO: Handle failure!
        abort();
    }
    DWORD length = csbi.dwSize.X * csbi.dwSize.Y;
    
    DWORD written;

    // Flood-fill the console with spaces to clear it
    FillConsoleOutputCharacter(hOut, TEXT(' '), length, topLeft, &written);

    // Reset the attributes of every character to the default.
    // This clears all background colour formatting, if any.
    FillConsoleOutputAttribute(hOut, csbi.wAttributes, length, topLeft, &written);

    // Move the cursor back to the top left for the next sequence of writes
    SetConsoleCursorPosition(hOut, topLeft);
}

实际上,与其每次重新绘制整个“框架”,你最好一次只绘制(或通过用空格覆盖来擦除)单个字符:

// x is the column, y is the row. The origin (0,0) is top-left.
void setCursorPosition(int x, int y)
{
    static const HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
    std::cout.flush();
    COORD coord = { (SHORT)x, (SHORT)y };
    SetConsoleCursorPosition(hOut, coord);
}

// Step through with a debugger, or insert sleeps, to see the effect.
setCursorPosition(10, 5);
std::cout << "CHEESE";
setCursorPosition(10, 5);
std::cout 'W';
setCursorPosition(10, 9);
std::cout << 'Z';
setCursorPosition(10, 5);
std::cout << "     ";  // Overwrite characters with spaces to "erase" them
std::cout.flush();
// Voilà, 'CHEESE' converted to 'WHEEZE', then all but the last 'E' erased

请注意,这也消除了闪烁,因为在重新绘制之前不再需要完全清除屏幕 - 您可以仅更改需要更改的内容而无需进行中间清除,因此先前的帧会逐步更新并持久存在,直到完全更新为止。
我建议使用双缓冲技术:在内存中有一个缓冲区表示控制台屏幕的“当前”状态,最初填充为空格。然后有另一个缓冲区表示屏幕的“下一个”状态。您的游戏更新逻辑将修改“下一个”状态(就像现在对您的数组所做的那样)。在绘制帧时,请勿先擦除所有内容。相反,同时遍历两个缓冲区,并仅写出从上一个状态(此时的“当前”缓冲区包含上一个状态)发生的更改。然后,将“下一个”缓冲区复制到“当前”缓冲区中以准备下一帧。
char prevBattleField[MAX_X][MAX_Y];
std::memset((char*)prevBattleField, 0, MAX_X * MAX_Y);

// ...

for (int y = 0; y != MAX_Y; ++y)
{
    for (int x = 0; x != MAX_X; ++x)
    {
        if (battleField[x][y] == prevBattleField[x][y]) {
            continue;
        }
        setCursorPosition(x, y);
        std::cout << battleField[x][y];
    }
}
std::cout.flush();
std::memcpy((char*)prevBattleField, (char const*)battleField, MAX_X * MAX_Y);

你甚至可以进一步批量运行更改,并将它们合并成单个I/O调用(这比许多单个字符写入的调用要便宜得多,但仍然与写入的字符数成比例地更昂贵)。
// Note: This requires you to invert the dimensions of `battleField` (and
// `prevBattleField`) in order for rows of characters to be contiguous in memory.
for (int y = 0; y != MAX_Y; ++y)
{
    int runStart = -1;
    for (int x = 0; x != MAX_X; ++x)
    {
        if (battleField[y][x] == prevBattleField[y][x]) {
            if (runStart != -1) {
                setCursorPosition(runStart, y);
                std::cout.write(&battleField[y][runStart], x - runStart);
                runStart = -1;
            }
        }
        else if (runStart == -1) {
            runStart = x;
        }
    }
    if (runStart != -1) {
        setCursorPosition(runStart, y);
        std::cout.write(&battleField[y][runStart], MAX_X - runStart);
    }
}
std::cout.flush();
std::memcpy((char*)prevBattleField, (char const*)battleField, MAX_X * MAX_Y);

在理论上,第一个循环比第二个快得多;但是在实践中,由于 std::cout 已经缓冲了写操作,所以可能不会有任何区别。但这是一个很好的例子(当底层系统中没有缓冲区时,它是一个常见的模式),因此我还是包括了它。
最后,请注意您可以将休眠时间缩短到1毫秒。Windows 实际上通常会休眠更长时间,通常高达15毫秒,但它会防止 CPU 核心达到100% 的使用率并带来最小的额外延迟。
请注意,这不是“真正”的游戏处理方式;它们几乎总是在每一帧清除缓冲区并重新绘制所有内容。他们不会出现闪烁,因为他们在 GPU 上使用双缓冲的等效物,其中前一帧保持可见状态,直到新帧完全完成绘制。
奖励:您可以将颜色更改为 8 种不同的系统颜色 中的任何一种,背景也可以。
void setConsoleColour(unsigned short colour)
{
    static const HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
    std::cout.flush();
    SetConsoleTextAttribute(hOut, colour);
}

// Example:
const unsigned short DARK_BLUE = FOREGROUND_BLUE;
const unsigned short BRIGHT_BLUE = FOREGROUND_BLUE | FOREGROUND_INTENSITY;

std::cout << "Hello ";
setConsoleColour(BRIGHT_BLUE);
std::cout << "world";
setConsoleColour(DARK_BLUE);
std::cout << "!" << std::endl;

2
到目前为止,我还没有实现你建议的所有东西,但是双缓冲方法似乎解决了闪烁问题(我甚至想在单独的线程上更新该缓冲区,以便在多核机器上进一步优化 - 我认为这样可以)。然而,我不完全确定 std::memcpy((char*)prevBattleField, (char const*)battleField, MAX_X * MAX_Y); 部分的作用,或者你所说的内存连续性是什么意思,因此需要进一步澄清。谢谢你的帮助! - ScottishTapWater
2
@James:如果可以的话,请不要将线程加入到混合中--这可能不会提高性能,但会大大复杂化您的代码,并可能引入微妙的非确定性错误(竞态条件)。memcpy只是将所有字节从battleField复制到prevBattleField,以便两者在之后包含相同的值(为下一帧设置prevBattleField)。'连续'区域只是意味着字节在内存中都相邻。对于a[x][y],给定列的所有字节都是连续的,但您希望给定行的所有字节都是连续的(a[y][x])。 - Cameron
3
@Ben:太棒了:D 这是我多年来一直想写的答案,所以当机会出现时我抓住了它。 - Cameron
刷新操作需要将所有写入 cout 的文本发送到控制台。默认情况下, cout 会在写入 endl 之前内部缓存所有写入的字符(通常这是期望的,因为它通过不逐个写入每个字符来提高性能,但在这种情况下,我们需要手动同步流,因为我们正在操作 cout 不知道的属性,因此不考虑其缓冲)。 - Cameron
1
NOMINMAX 防止 windows.h 定义 minmax 宏,这些宏会干扰 std::minstd::maxWIN32_LEAN_AND_MEAN 防止 windows.h 包含其更为晦涩的子头文件,从而减少全局命名空间污染。在此处可以省略它们,但是当包含 windows.h 时我默认定义它们。 - Cameron
显示剩余2条评论

11

system("cls")你的问题所在。为了更新框架,你的程序必须产生另一个进程并加载和执行另一个程序。这相当昂贵。cls清除你的屏幕,这意味着在某段时间内(直到控制返回到你的主进程),它完全不显示任何内容。这就是闪烁的原因。你应该使用像ncurses这样的库,它允许你显示“场景”,然后将游标位置移动到<0,0> 而不会修改屏幕上的任何东西,并重新显示你的场景“覆盖”旧的场景。这样你就可以避免闪烁问题,因为你的场景总是会显示一些东西,没有“完全空白屏幕”的步骤。


2

一种方法是将格式化数据写入字符串(或缓冲区),然后将缓冲区块写入控制台。

每次调用函数都会带来开销。尝试在一个函数中完成更多的工作。在输出方面,这可能意味着每个输出请求需要大量文本。

例如:

static char buffer[2048];
char * p_next_write = &buffer[0];
for (int y = 0; y < MAX_Y; y++)
{
    for (int x = 0; x < MAX_X; x++)
    {
        *p_next_write++ = battleField[x][y];
    }
    *p_next_write++ = '\n';
}
*p_next_write = '\0'; // "Insurance" for C-Style strings.
cout.write(&buffer[0], std::distance(p_buffer - &buffer[0]));

输入/输出操作在执行方面是很昂贵的,因此最好的方法是尽可能地增加每个输出请求的数据量。


1

如果您更新的区域足够大,则采用接受的答案仍会导致渲染闪烁。即使您将单个水平线动画移动从顶部到底部,大多数时间您仍会看到它像这样:

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

这是因为你会看到旧帧正在被新帧覆盖的过程。对于视频或3D渲染等复杂场景,这几乎是不可接受的。正确的方式是使用双缓冲技术。其思想是将所有“像素”绘制到屏幕外缓冲区中,完成后一次性显示。很高兴Windows控制台非常支持这种方法。请查看下面如何进行双缓冲的完整示例:
#include <chrono>
#include <thread>
#include <Windows.h>
#include <vector>


const unsigned FPS = 25;
std::vector<char> frameData;
short cursor = 0;

// Get the intial console buffer.
auto firstBuffer = GetStdHandle(STD_OUTPUT_HANDLE);

// Create an additional buffer for switching.
auto secondBuffer = CreateConsoleScreenBuffer(
    GENERIC_READ | GENERIC_WRITE,
    FILE_SHARE_WRITE | FILE_SHARE_READ,
    nullptr,
    CONSOLE_TEXTMODE_BUFFER,
    nullptr);

// Assign switchable back buffer.
HANDLE backBuffer = secondBuffer;
bool bufferSwitch = true;

// Returns current window size in rows and columns.
COORD getScreenSize()
{
    CONSOLE_SCREEN_BUFFER_INFO bufferInfo;
    GetConsoleScreenBufferInfo(firstBuffer, &bufferInfo);
    const auto newScreenWidth = bufferInfo.srWindow.Right - bufferInfo.srWindow.Left + 1;
    const auto newscreenHeight = bufferInfo.srWindow.Bottom - bufferInfo.srWindow.Top + 1;

    return COORD{ static_cast<short>(newScreenWidth), static_cast<short>(newscreenHeight) };
}

// Switches back buffer as active.
void swapBuffers()
{
    WriteConsole(backBuffer, &frameData.front(), static_cast<short>(frameData.size()), nullptr, nullptr);
    SetConsoleActiveScreenBuffer(backBuffer);
    backBuffer = bufferSwitch ? firstBuffer : secondBuffer;
    bufferSwitch = !bufferSwitch;
    std::this_thread::sleep_for(std::chrono::milliseconds(1000 / FPS));
}

// Draw horizontal line moving from top to bottom.
void drawFrame(COORD screenSize)
{
    for (auto i = 0; i < screenSize.Y; i++)
    {
        for (auto j = 0; j < screenSize.X; j++)
            if (cursor == i)
                frameData[i * screenSize.X + j] = '@';
            else
                frameData[i * screenSize.X + j] = ' ';
    }

    cursor++;
    if (cursor >= screenSize.Y)
        cursor = 0;
}

int main()
{
    const auto screenSize = getScreenSize();
    SetConsoleScreenBufferSize(firstBuffer, screenSize);
    SetConsoleScreenBufferSize(secondBuffer, screenSize);
    frameData.resize(screenSize.X * screenSize.Y);

    // Main rendering loop: 
    // 1. Draw frame to the back buffer.
    // 2. Set back buffer as active.
    while (true)
    {
        drawFrame(screenSize);
        swapBuffers();
    }
}

在这个例子中,出于简单起见,我选择了一个静态的FPS值。您可能还想引入一些功能来通过计算实际FPS来稳定帧频输出。这将使您的动画在控制台吞吐量独立的情况下运行平稳。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接