Search code examples
c++performancecolorsiobuffer

How can I write coloured ASCII characters directly to the console's buffer?


I am a student that has become quite interested in the C++ language and I am currently working on a little project involving printing large number of coloured ASCII characters on the Windows Terminal which is an optimised version of the old cmd.exe. My project consists of creating small "ASCII videos" by converting frames from a video into ASCII images and printing them sequentially (see image or video link). I am currently using ANSI escape sequences like \033[38;2;⟨r⟩;⟨g⟩;⟨b⟩m to manually set the colours I want for each ASCII character like so :

string char_ramp("$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\|()1{}[]?-_+~<>i!lI;:,\" ^ `'. ");
int brightness(some_number);

(...)

current_frame += string{} + "\033[38;2;" + to_string(r) + ";" + to_string(g) + ";" + to_string(b) + "m" + char_ramp[(int)(greyscale_index / 255 * (char_ramp.size() - 1)) / brightness] + "\033[0m";

Basically, I am building the frames line by line by adding the ASCII character corresponding to the current pixel's level of brightness on a grey scale character ramp and then I colourise it with an ANSI escape sequence.

But doing it this way seems to be very demanding for the terminal because I get frame drops when I print the colored ASCII frames. Say for example that if the original video was at 30 fps, the colored ASCII video would drop to roughly 15 fps.

I tried removing the colours and it seemed to be working fine, no frame drops.

Some friends suggested I should be setting the buffer data directly instead of doing all the extra logic of printing lines. However, I don't know how I could do it without changing my way of constructing the ASCII frames as I really need them to be stored in a specific way, or at least stored in a way that allows me to control the output rate. I heard that the WriteConsoleOutputCharacter function allows me to do some things but I didn't understand how I could implement it in my code.

I don't really care about memory efficiency here so wouldn't there be a way to set the frame data into the buffer and then just iterate through the frames I want to display?

If that is just a wrong call, then what would be an efficient way of displaying these ascii frames on the terminal?


Solution

  • Some time ago I wrote some code to do stream-style output directly to a Windows console, with color support.

    // WinBuf.hpp:
    
    #pragma once
    #include <ios>
    #include <ostream>
    #include <windows.h>
    
    //! A C++ streambuf that writes directly to a Windows console
    class WinBuf : public std::streambuf
    {
        HANDLE h;
    
    public:
        //! Create a WinBuf from an Windows handle
        //! @param h handle to a Windows console
        WinBuf(HANDLE h) : h(h) {}
        WinBuf(WinBuf const &) = delete;
        WinBuf &operator=(WinBuf const &) = delete;
    
        //! Return the handle to which this buffer is attached
        HANDLE handle() const { return h; }
    
    protected:
        virtual int_type overflow(int_type c) override
        {
            if (c != EOF)
            {
                DWORD written;
                WriteConsole(h, &c, 1, &written, nullptr);
            }
            return c;
        }
    
        virtual std::streamsize xsputn(char_type const *s, std::streamsize count) override
        {
            DWORD written;
            WriteConsole(h, s, DWORD(count), &written, nullptr);
            return written;
        }
    };
    
    //! A C++ ostream that  writes via the preceding WinBuf
    class WinStream : public virtual std::ostream
    {
        WinBuf buf;
    
    public:
        //! Create stream for a Windows console, defaulting to standard output
        WinStream(HANDLE dest = GetStdHandle(STD_OUTPUT_HANDLE))
            : buf(dest), std::ostream(&buf)
        {
        }
    
        //! return a pointer to the underlying WinBuf
        WinBuf *rdbuf() { return &buf; }
    };
    
    //! Provide the ability to set attributes (text colors)
    class SetAttr
    {
        WORD attr;
    
    public:
        //! Save user's attribute for when this SetAttr object is written out
        SetAttr(WORD attr) : attr(attr) {}
    
        //! Support writing the SetAttr object to a WinStream
        //! @param w a WinStream object to write to
        //! @param c An attribute to set
        friend WinStream &operator<<(WinStream &w, SetAttr const &c)
        {
            WinBuf *buf = w.rdbuf();
            auto h = buf->handle();
            SetConsoleTextAttribute(h, c.attr);
            return w;
        }
    
        //! support combining attributes
        //! @param r the attribute to combine with this one
        SetAttr operator|(SetAttr const &r)
        {
            return SetAttr(attr | r.attr);
        }
    };
    
    //! Support setting the position for succeeding output
    class gotoxy
    {
        COORD coord;
    
    public:
        //! Save position for when object is written to stream
        gotoxy(SHORT x, SHORT y) : coord{ .X = x, .Y = y} {}
    
        //! support writing gotoxy object to stream
        friend WinStream &operator<<(WinStream &w, gotoxy const &pos)
        {
            WinBuf *buf = w.rdbuf();
            auto h = buf->handle();
            SetConsoleCursorPosition(h, pos.coord);
            return w;
        }
    };
    
    //! Clear the "screen"
    class cls
    {
        char ch;
    
    public:
        //! Create screen clearing object
        //! @param ch character to use to fill screen
        cls(char ch = ' ') : ch(ch) {}
    
        //! Support writing to a stream
        //! @param os the WinStream to write to
        //! @param c the cls object to write
        friend WinStream &operator<<(WinStream &os, cls const &c)
        {
            COORD tl = {0, 0};
            CONSOLE_SCREEN_BUFFER_INFO s;
            WinBuf *w = os.rdbuf();
            HANDLE console = w->handle();
    
            GetConsoleScreenBufferInfo(console, &s);
            DWORD written, cells = s.dwSize.X * s.dwSize.Y;
            FillConsoleOutputCharacter(console, c.ch, cells, tl, &written);
            FillConsoleOutputAttribute(console, s.wAttributes, cells, tl, &written);
            SetConsoleCursorPosition(console, tl);
            return os;
        }
    };
    
    //! Provide some convenience instances of the SetAttr object
    //! to (marginally) ease setting colors.
    extern SetAttr red;
    extern SetAttr green;
    extern SetAttr blue;
    extern SetAttr intense;
    
    extern SetAttr red_back;
    extern SetAttr blue_back;
    extern SetAttr green_back;
    extern SetAttr intense_back;
    

    The attributes are defined in a matching .cpp file:

    // Winbuf.cpp:
    #include "WinBuf.hpp"
    
    SetAttr red{FOREGROUND_RED};
    SetAttr green{FOREGROUND_GREEN};
    SetAttr blue{FOREGROUND_BLUE};
    SetAttr intense{FOREGROUND_INTENSITY};
    
    SetAttr red_back{BACKGROUND_RED};
    SetAttr green_back{BACKGROUND_GREEN};
    SetAttr blue_back{BACKGROUND_BLUE};
    SetAttr intense_back{BACKGROUND_INTENSITY};
    

    Here's a quick demo/test program to show how it's used:

    // test_Winbuf.cpp
    #include "winbuf.hpp"
    
    int main()
    {
        WinStream w;
    
        // the colors OR together, so red | green | blue gives white:
        auto color = red | green | blue | blue_back;
    
        w << color << cls() << gotoxy(10, 4) << "This is a string\n";
        for (int i = 0; i < 10; i++)
            w << "Line: " << i << "\n";
    
        w << (green | blue_back);
    
        for (int i = 0; i < 10; i++)
            w << "Line: " << i + 10 << "\n";
    
        w << gotoxy(20, 10) << "Stuck in the middle with you!\n";
    
        w << gotoxy(0, 30) << color << "The end\n";
    }
    

    At in my experience, this is quite a bit faster than using ANSI escape sequences (and, for what little it's worth, also works on older versions of Windows).