2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【C++】ターミナルで「Bad Apple!!」を120FPS再生するためにやったこと

Last updated at Posted at 2025-12-15

はじめに

見習いエンジニアの学生です。本プロジェクトは僕自身がC/C++を勉強、及び最適化に関する勉強をするために開発しました。ロマンあふれるものになります。
普段は高専ロボコンで、ロボット制御を担当しています。

本記事の内容は、授業や講義ではなく、すべて個人で試行錯誤しながら実装・検証したものです。
AIとネット上の情報に助けられました。

アスキーアート(ASCII Art, AA)とは

コンピューターの文字(英数字や記号)を縦横に並べて、絵や図、キャラクターなどを表現する技法です。

  • 顔文字:(^_^)のように短い表情を表すもの
  • 数行〜数百行の文字で複雑なイラストを描くテキストアート

Bad Apple!!

「ロマン」の話ですが、エンジニア界隈には「画面があるものなら何でも『Bad Apple!!』を流す」という文化があります。(電卓、オシロスコープ、タスクマネージャーなど)

完成品

Bad Apple Terminal Output

フル動画

ターミナルで Bad Apple!! を120FPS 文字だけで再現してみた

動作環境

CPU: Intel Core i5 12400F
メモリ: 32GB
GPU: RTX 4060
OS: Ubuntu 24.04 LTS

また、以下の環境でも動作確認しています。
CPU: Intel Core i5 1235U
メモリ: 16GB
GPU: Intel内蔵
OS: Ubuntu 22.04 LTS

ASCIIアート化・同期再生システム

OpenCVとマルチスレッドを用いて、MP4動画をコンソール上で「ASCIIアート(文字列)」としてリアルタイム再生し、音声と同期させるシステムの内部構造を解説していきます。

  1. 動画フレームの画像解析と文字列変換(点字フォント活用)
  2. 外部コマンドによる音声抽出
  3. 音声再生と映像描画の精密な時間同期

ターミナルという制約の中で、高フレームレートかつスムーズな再生を実現するためにプロデューサー・コンシューマーモデルを採用しています。

後から調べて分かりましたが、この構成はいわゆる「プロデューサー・コンシューマーモデル」でした。

使用ライブラリ

  • OpenCV (C++)
    映像取得・画像処理、高速なフレーム読み込み、リサイズ、グレースケール変換機能のため。

  • SFML (Audio)
    軽量で扱いやすく、BGMの再生が容易であるため。

  • FFmpeg (CLI)
    動画からWAV形式へ音声を分離・変換する前処理用として、system()関数経由で使用。

  • OpenMP
    ASCII変換処理のループを並列化し、CPU使用率を効率化するため。

  • Standard Library
    std::thread std::mutex std::chronoを使用し、処理落ちを防ぐ並列構造を実装。

処理の流れ

システムは大きく分けて「初期化フェーズ」と、2つの並列スレッドによる「再生フェーズ」で構成されています。

前処理(初期化)

プログラム起動時、即座にFFmpegコマンドを発行し、動画ファイルから音声データを抽出します。

// 外部コマンド呼び出しによるWAV生成
std::string commands = "ffmpeg -y -i " + FILENAME + " -vn output.wav";
system(commands.c_str());

並列処理モデル

処理速度を最大化するため、「フレーム生成」と「描画」を別スレッドに分割しています。

フレーム生成スレッド

動画データの読み込みと変換を担当します。

  1. Read cv::VideoCapture でフレームを取得
  2. Process: 画像をリサイズ・グレースケール化し、ASCII文字列へ変換
  3. Buffer: 変換済みの文字列データを std::vector<std::string>に格納
    ※排他制御(Mutex)により、描画スレッドとのデータ競合を防止。

描画・再生スレッド

画面への出力と同期を担当します。

  1. Play: SFMLで音声を再生開始
  2. Sync: 経過時間を計測し、現在の時間に合致するフレームをバッファから取得
  3. Draw: 標準出力(stdout)へ高速に書き出し
  4. Clean: 表示済みフレームのメモリを即座に解放

コアロジックの解説

アスペクト比の補正

コンソールのフォントは縦長であるため、通常の比率でリサイズすると映像が潰れてしまいます。これを防ぐため、リサイズ時に特殊な係数を掛けています。

cv::Mat resize(const cv::Mat& image, int new_height) {
    int old_width = image.cols;
    int old_height = image.rows;
    float aspect_ratio = static_cast<float>(old_width) / static_cast<float>(old_height);
    int new_width = static_cast<int>(aspect_ratio * new_height * 2.65);
    cv::Mat resized_image;
    cv::resize(image, resized_image, cv::Size(new_width, new_height));
    return resized_image;
}

高密度ASCIIアート変換

通常のアルファベットではなく、点字(Braille Patterns)を使用することで、より繊細な濃淡表現を実現しています。

  1. マッピング配列: {"⣿", "⣾", ... " "}
  2. ロジック: 画素の輝度(0〜255)をバケットサイズ(25)で割り、対応する点字文字に置換
  3. OpenMPによる並列化と、画素への直接的なポインタアクセスを組み合わせることで、CPUリソースを最大限に活用
std::string modify(const cv::Mat& image) {
    std::vector<std::string> output(image.rows);
    
    #pragma omp parallel for
    for (int i = 0; i < image.rows; ++i) {
        std::ostringstream oss;
        const uchar* row_ptr = image.ptr<uchar>(i);
        for (int j = 0; j < image.cols; ++j) {
            int pixel_value = row_ptr[j];
            oss << ASCII_CHARS[pixel_value / 25];
        }
        oss << "\n";
        output[i] = oss.str();
    }
    
    std::ostringstream final_output;
    final_output << "\033[H";
    for (const auto& line : output) {
        final_output << line;
    }
    final_output << "\033[0m";
    return final_output.str();
}

※ std::ostringstream は高速とは言えないため、さらなる最適化余地(固定バッファ化等)はあります。

高速描画

通常、C++での出力には std::coutprintf を使いますが、これらはバッファリングやフォーマット解析のオーバーヘッドがあり、大量のテキストを書き換える用途には速度不足で不可能でした。

そのため、低レイヤーのシステムコールである write() 関数を使用し、標準出力(ファイルディスクリプタ1)へ直接バイト列を流し込むことで、描画のボトルネックを解消しています。

#include <unistd.h>

// std::cout << frame_data; ではなく...
// ファイルディスクリプタ 1 (stdout) に直接書き込む
write(STDOUT_FILENO, frame_data.c_str(), frame_data.size());

ANSI制御の最小化

ターミナル描画では、ANSIエスケープシーケンスのコストが無視できません。
そのため、本実装では以下の方針を取っています

  • 使用するANSI制御は最小限(\033[H と必要最低限のリセットのみ)
  • clear screen (\033[2J) や行単位のカーソル移動は使用しない
  • 1フレーム分の描画データを1つの連続したバッファにまとめ、write()で一括出力

これにより、ターミナル側の状態遷移回数と描画負荷を大幅に削減しています。

差分描画も試したが、今回は逆効果だった

描画負荷を下げるため、前フレームとの差分のみを描画する方式も検討・実装しました。
しかし結果としては、全面描画よりも圧倒的に遅くなりました。

主な理由は以下です。

  • Bad Apple!! はフレーム間の変化量が大きく、差分量が少なくならない
  • 差分検出のための比較処理・分岐が増え、CPU負荷が上昇
  • 行単位・部分描画により、ANSIカーソル制御が増加

このため「全面描画+ANSI制御最小化」という戦略を採用しています。

音声同期

単純な sleep だけでは処理遅延により音ズレが発生します。

  1. 開始時刻の記録: 音楽再生と同時に std::chrono で計測開始
  2. フレームスキップ
// 経過時間から「今あるべきフレーム番号」を算出
int expected_frame_index = static_cast<int>(elapsed_time.count() * fps);

// 現在の描画位置が遅れている場合、追いつくまでループ(フレームを飛ばす)
while (i < expected_frame_index ...) { ++i; }

これにより、描画処理が重くなった場合でも映像がコマ落ちするだけで、音声とのズレは発生しません。

メモリ管理とパフォーマンス

動画全フレームを文字列としてメモリに保持すると、長時間の動画ではメモリ不足に陥ります。これを防ぐため、以下のメモリ管理を行っています。

  • 動的なメモリ解放
    描画スレッドが表示を終えたフレームデータは、即座に clear() および shrink_to_fit()され、物理メモリから破棄

現状は可変長文字列を都度確保・解放しており、効率とコストが課題だと考え、固定長リングバッファ化を検討しています。

メモリ管理
大苦戦中ですので、参考にしないでください。
メモリムズカシイヨオオ

ビルド

OpenCV、SFML、OpenMPを使用し、最適化オプションが設定されています。

最適化オプション

target_compile_options(Bad-Apple PRIVATE 
    -O3 
    -march=native 
    -ffast-math 
    -fomit-frame-pointer 
    -flto
    -fopenmp
)
  • -O3: 最も高いレベルの最適化
  • -march=native: 「今このコンパイルを行っているPCのCPU」に特化した命令セット
  • -ffast-math: 浮動小数点演算の厳密な規格を無視して、計算速度を優先
  • -fomit-frame-pointer: デバッグ用の情報をレジスタから排除し、その分を計算用に回して高速化
  • -flto (Link Time Optimization): ファイル単位ではなく、プログラム全体を見渡して関数呼び出しなどを最適化
  • -fopenmp: OpenMPを使用するため

CUDAも試したが、今回は逆効果

ASCII変換処理をCUDAでGPUに投げればさらに高速化できると考えましたが、結果的にはCPU実装より遅くなりました。
主な原因は以下の通りだと考えています。

  • フレームごとにCPU→GPU→CPUのメモリ転送が発生
  • 1フレームあたりの処理量が小さく、カーネル起動コスト+転送オーバーヘッドが支配的
  • ターミナル描画自体はCPU側で行う必要があり、GPU処理を活かしきれなかった

フレームをまとめてGPU側で完結させる構成(GPU描画や巨大バッチ処理)であれば、CUDAが有効になる可能性はあると思っています。

Alacritty

使用しているターミナルはAlacrittyというものです。
Rust製でGPUアクセラレーションを利用した高速なターミナルエミュレータです。シンプルさとパフォーマンスを重視して開発されています。
表示に関しては爆速ですのでこれを採用しています。

ソースコード

GitHub
mainブランチがBad Apple ASCII Art Playerになります。
color-v2ブランチがASCII Art Playerの発展系で、フルカラーで動画を再生します。
これは別の記事で紹介したいと思います。

終わりに

本来、文字を表示するだけの『ターミナル画面』で、映像と音楽をズレなく、しかも高画質に再生しようとする執念からここまできています。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?