Hatena::ブログ(Diary)

2nd beam このページをアンテナに追加 RSSフィード

2010年02月24日(水)

[] 2. 図形の描画

めちゃくちゃ間があきましたが, SDL 1.3 の続きです. 今回は画像を使わない基本的な描画処理について. 相変わらずヘッダーとソースを見ながら何となく書いてるので, 間違ったことを書く可能性がありますがご容赦を.

注意: 前回の記事から今までに SDL 1.3 において, SDL_WindowID の代わりに SDL_Window* を使うように仕様の変更がありました. まだ開発中のライブラリーというだけあってこういう変更は今後もあるかもしれません. 大幅な改修はもうないと思いますが...

SDL 1.3 の画面描画は, レンダラー (レンダリングコンテキスト) を設定した上で各種の描画関数を呼び出す, という形で行います. レンダラーは各ウィンドウに一つずつ設定できます.

SDL_CreateRenderer 関数でレンダラーを作成し, SDL_SelectRenderer 関数でそれをカレントにします. 各種の描画関数は明示的にレンダラーを受け取りません. これらは SDL_SelectRenderer 関数で最後に設定されたレンダラーに対して描画を行うようです.

int SDL_CreateRenderer(SDL_Window* window, int index, Uint32 flags);
window に対するレンダラーを作成する. index はレンダリングドライバーのインデックスを表す. -1 を指定すると flags で指定した条件に合う最初のドライバーを自動的に選択する. flags は以下の enum 値の OR 結合を指定する. まず画面の更新方法について, SDL_RENDERER_SINGLEBUFFER でシングルバッファーを使う. SDL_RENDERER_PRESENTCOPY を指定するとダブルバッファーだが, 画面更新はフリップではなく全面コピーによって行う. SDL_RENDERER_PRESENTFLIP2 は一般的なダブルバッファーのフリップによる更新. SDL_RENDER_PRESENTFLIP3 はトリプルバッファー方式である. 残りのフラグは更新に関するオプションである. SDL_RENDERER_PRESENTDISCARD を指定すると, 画面更新後のバックバッファーは未定義状態となる. SDL_RENDERER_PRESENTVSYNC を指定すると垂直同期を行う. SDL_RENDERER_ACCLERATED を指定するとハードウェアアクセラレーションを利用しようとする. この関数は成功すれば 0 を, 失敗すれば -1 を返す.
int SDL_SelectRenderer(SDL_Window* window);
window に設定されたレンダラーをカレントにする. window に対してレンダラーが設定されていなければ -1 を返し, そうでなければ 0 を返す.
void SDL_DestroyRenderer(SDL_Window* window);
window に対して設定されたレンダラーを破棄し, そのレンダラーに紐付けられたテクスチャー (多分次回触れます) をすべて解放する.

設定したレンダラーに対して毎フレーム描画を行うことになります. 普通はまず画面全体を黒などで塗りつぶすことから始めるでしょう. この処理は SDL_SetRenderDrawColor 関数で描画色を設定した後に SDL_RenderClear 関数を呼び出して行います.

int SDL_SetRenderDrawColor(Uint8 r, Uint8 g, Uint8 b, Uint8 a);
描画色を設定する. この描画色は画面のクリアーおよび各種基本図形の描画すべてに使われる. カレントなレンダラーがない場合は -1 を, そうでなければ 0 を返す.
int SDL_RenderClear();
現在の描画色で画面全体を塗りつぶす. カレントなレンダラーがない場合は -1 を, そうでなければ 0 を返す.

関数名に Render と入っているものは大抵, カレントなレンダラーに対しての処理です. SDL_RenderClear は後述する SDL_RenderFillRect を引数 NULL で呼び出すのと等価ですが, レンダリングドライバーによって専用の関数が用意されている場合にはそれを利用します (おそらく効率的) *1.

上のように, 描画色にはアルファー値を設定することができますが, アルファーブレンドを使う場合には明示的に有効化する必要があります.

int SDL_SetRenderDrawBlendMode(int blendMode);
カレントのレンダラーのブレンドモードを設定する. SDL_BLENDMODE_NONE を設定するとブレンドを行わない. SDL_BLENDMODE_MASK を設定するとアルファー値が 0 でない場合のみ描画する. SDL_BLENDMODE_BLEND の場合は一般的なアルファーブレンドを行う. SDL_BLENDMODE_ADD を指定すると加算ブレンドを行う. SDL_BLENDMODE_MOD を指定すると乗算ブレンドを行う. この関数は, カレントのレンダラーが取得できない場合は -1 を, そうでなければ 0 を返す.
int SDL_GetRenderDrawBlendMode(int* blendMode);
カレントのレンダラーの現在のブレンドモードを取得し, *blendMode に代入する. カレントのレンダラーが取得できない場合は -1 を, そうでなければ 0 を返す.

SDL が描画できる図形は点, 線分, 矩形のみです. 矩形については中を塗りつぶすこともできるし, 枠だけを描画することもできます. この他に画像などを読み込んだテクスチャーから矩形を描画することもできますが, これは次回以降に.

以下がこれらの図形を描画する関数です. カレントなレンダラーに現在の描画色で描画する, という点はすべて共通です. また戻り値は, カレントのレンダラーが得られない場合は -1, そうでなければ 0 です.

int SDL_RenderDrawPoint(int x, int y);
座標 (x, y) に点を描画する.
int SDL_RenderDrawPoints(const SDL_Point* points, int count);
points で指定された count 個の座標に点を描画する.
int SDL_RenderDrawLine(int x1, int y1, int x2, int y2);
点 (x1, y1) と点 (x2, y2) を結ぶ線分を描画する.
int SDL_RenderDrawLines(const SDL_Point* points, int count);
points で指定された count 個の点を順に結ぶ折れ線を描画する.
int SDL_RenderDrawRect(const SDL_Rect* rect);
rect で指定された矩形のアウトラインを描画する. rect に NULL を指定した場合, 画面全体のアウトラインを描画する. SDL_RenderDrawRects(&rect, 1) と等価.
int SDL_RenderDrawRects(const SDL_Rect** rect, int count);
rect で指定された count 個の矩形の枠をすべて描画する.
int SDL_RenderFillRect(const SDL_Rect* rect);
rect で指定された矩形を塗りつぶす. rect に NULL を指定した場合, 画面全体を塗りつぶす (ただしその場合, 上述した通り SDL_RenderClear の方が適している). SDL_RenderFillRects(&rect, 1) と等価.
int SDL_RenderFillRects(const SDL_Rect** rect, int count);
rect で指定された count 個の矩形をすべて塗りつぶす.

SDL_Point は 2 次元の座標を表す構造体で, SDL_Rect は 2 次元の矩形を表す構造体です. それぞれ以下のような定義になっています.

// SDL_Rect.h
typedef struct
{
    int x;
    int y;
} SDL_Point;

typedef struct SDL_Rect
{
    int x, y;
    int w, h;
} SDL_Rect;

ダブルバッファーやトリプルバッファーを使用している場合, 描画が終わったら画面に反映させる必要があります. これは SDL_RenderPresent 関数で行います.

void SDL_RenderPresent(void);
バックバッファーの内容を画面に反映される. レンダラー作成時の設定に従い, フリップまたはコピーを行う.

これで SDL 1.3 による画像を使わない基本的な描画処理は終わりです. 最後に 2 枚の矩形をアルファーブレンドで描画するサンプルを上げておきます.

#include <iostream>
#include <SDL.h>

// 描画処理
void render() {
  // 画面を黒一色でクリアーする.
  SDL_SetRenderDrawColor(0, 0, 0, 255);
  SDL_RenderClear();

  // アルファーブレンドを有効にする.
  SDL_SetRenderDrawBlendMode(SDL_BLENDMODE_BLEND);

  // 矩形を赤で塗りつぶす.
  {
    SDL_SetRenderDrawColor(255, 0, 0, 255);
    SDL_Rect rect = { 50, 100, 200, 300 };
    SDL_RenderFillRect(&rect);
  }

  // 矩形を半透明の青で塗りつぶす.
  {
    SDL_SetRenderDrawColor(0, 0, 255, 128);
    SDL_Rect rect = { 100, 50, 300, 200 };
    SDL_RenderFillRect(&rect);
  }

  // 画面を更新する.
  SDL_RenderPresent();
}

int main(int, char**) {
  if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER)) return 1;

  SDL_Window* window = SDL_CreateWindow("Render rectangles",
                                        SDL_WINDOWPOS_UNDEFINED,
                                        SDL_WINDOWPOS_UNDEFINED,
                                        640,
                                        480,
                                        SDL_WINDOW_SHOWN);

  if (SDL_CreateRenderer(window, 0, SDL_RENDERER_PRESENTFLIP2) == -1) {
    SDL_Quit();
    return 1;
  }

  SDL_Event event;
  for (bool quit = false;;) {
    // イベントがなくなるまで処理し続け, なければ描画処理を行う.
    while (SDL_PollEvent(&event)) {
      switch (event.type) {
        case SDL_QUIT:
          quit = true;
          break;
        case SDL_WINDOWEVENT:
          quit = event.window.event == SDL_WINDOWEVENT_CLOSE;
          break;
        default: break;
      }
    }
    if (quit) break;
    render();
    SDL_Delay(10);
  }

  SDL_Quit();
  return 0;
}

*1:裏技として SDL_RenderFillRect(s) で画像を描画することができる場合があります. たとえばレンダリングドライバーに OpenGL を用いている場合に, SDL を経由せずに直接 OpenGL テクスチャーをバインドしてから SDL_RenderFillRect(s) を呼び出せば, テクスチャーレンダリングを行ってくれます (内部的には glRecti (OpenGL の場合) や glDrawArrays (OpenGL ES の場合) を呼び出すだけなので). さらに変換行列を設定しておけば座標変換もしてくれます. このような気持ち悪いことをするならば, SDL_RenderFillRect(NULL) と SDL_RenderClear() は違う効果を生むことになります.

2010年02月22日(月)

[][] canvas でトランジションをしたら

Javascript で 2D ゲームを作りたいと思ったらいろんなことができるようになってきたけど, 画面トランジションは躊躇するところ. canvas 要素の ImageData を使えばピクセル操作ができるので, 原理的には可能だけど画面全体でやったら重そう.

と思ってたけど実際試してみたことないなあと思ってやってみました. 今時間ないから詳細書きませんが, Safari で試したところによると, 毎フレーム 640x480 の ImageData を 2 つピクセル単位で合成して別の ImageData に書き, それを canvas に入れる, という操作はやっぱり重いみたいで, 手元の Macbook で 20 fps ちょいまで落ちました.

ちなみにやってみたのはフェードするカーテントランジション. ユニバーサルトランジションならもう一枚ルール画像の ImageData を読みながらになるので, さらに重くなりそう. 逆にフェードカーテンがやりたいだけなら, 実際にはフレームごとに書き換える必要があるのは画面の一部だけで, 部分更新でやればそこそこの速度を保てました (フェードの幅を 64 pixel にしたら 50 fps くらいは保てた). 低スペック環境でもそこそこ動いてほしいという場合はこっちも厳しいかもしれないけど.

canvas のピクセル配列は一般の配列より (大抵の環境で) 少しだけ速いらしいです. 整数型固定とか, いろいろ制約が多いので. あと, こういうことやるときには効率が重要ですが, Javascript の場合現状は手動の最適化がかなり効くらしいです. スコープチェーンをできるだけたどらないようにするとか, その手の.

とはいっても本当にバリバリ動くゲームを作るなら今後は WebGL ですよね.

2010年02月11日(木)

[][] FFmpeg+SDL で音声再生 (2)

前回の続きです.

ファイルを開く

音声ストリームを持つコンテナーファイルを開くには次のようにします.

// ファイルを開く
AVFormatContext* fctx;
if (av_open_input_file(&fctx, "test.mp3", NULL, 0, NULL)) ...;
if (av_find_stream(fctx) < 0) ...;

// 音声ストリームを探す
int nb_streams = fctx->nb_streams;
int audio_stream_index = -1;
for (int i = 0; i < nb_streams; ++i) {
  if (fctx->streams[i]->codec->codec_type == CODEC_TYPE_AUDIO) {
    audio_stream_index = i;
    break;
  }
}
if (audio_strea_index < 0) ...;

// 対応するコーデックを開く
AVCodecContext* cctx = fctx->streams[audio_stream_index]->codec;
AVCodec* codec = avcodec_find_decoder(cctx->codec_id);
if (!codec || avcodec_open(cctx, codec) < 0) ...;

if 文の後に ... となっているところはエラー処理です. av_open_input_file() で音声を含むメディアファイルを開きます. 後ろの NULL, 0, NULL はファイルの開き方 (フォーマットの指定, オプションバッファー, などなど) のデフォルト指定です. av_find_stream() でストリームがあるかどうかを調べます.

その後, 音声ストリームを探します. ストリーム情報は fctx->streams[] に並んでいます. 動画ストリームも探す場合は CODEC_TYPE_VIDEO となるストリームを一緒に探します.

対応するコーデック (デコーダー) は avcodec_find_decoder() で見つけて avcodec_open() で開きます. まあここら辺は関数名の通り.

パケットキュー

ストリームを開いたら, パケットを読み出してコールバック関数に送り, デコードして SDL のオーディオバッファーに書き込みます. このとき, パケットの読み込みはスレッドを分けるのが一般的です. ここでもその方法で行きます.

パケットはキューで管理します. パケット読み込みスレッドはパケットを読んでキューに次々プッシュしていきます. キューにある程度溜まったらポップされるまで待機します. コールバック関数はキューからパケットをポップし, デコードして, バッファーに書き込みます. バッファーに書き終えるまでこれを続けます.

まずはパケットキューを定義します.

class PacketQueue {
 public:
  PacketQueue() : size_(0) {}

  void push(AVPacket& packet) {
    av_dup_packet(&packet);
    packets_.push_back(packet);
    size_ += packet.size;
  }

  bool pop(AVPacket& packet) {
    if (packets_.empty()) return false;
    packet = packets_.front();
    packets_.pop_front();
    size_ -= packet.size;
    return true;
  }

  int dataSize() const { return size_; }

 private:
  std::deque<AVPacket> packets_;
  int size_;
};

(スレッド同期に関するコードは省略しています. 本来は必須)

push() で渡される packet は後述する av_read_frame() で取得したパケットです. packet には圧縮された音声データへのポインター (packet.data) が含まれますが, av_read_frame() から取得した段階では内部バッファーを指していることがあります. この場合次の av_read_frame() 呼び出しでその領域のデータは上書きされてしまいます. よってデータを保持するためには別の領域を確保してデータをコピーする必要があります. これを行ってくれるのが av_dup_packet() です. この関数は packet が指すデータが内部バッファーかどうかを判別して, 必要な場合にだけコピーを行います.

size_ および dataSize() はキューに溜まっているパケットの総データ量を表します.

次にパケットを読み込むスレッドです.

void queueingThread() {
  static int const MAX_BUFFER_SIZE = ...;
  AVPacket packet;
  PacketQueue* queue = ...;  // コールバック関数と共有する
  AVFormatContext* fctx = ...;  // 先ほど取得したもの
  int stream_index = ...;  // 先ほど取得したもの
  bool* quit = ...;  // 終了フラグ

  while (!*quit) {
    if (queue->dataSize() > MAX_BUFFER_SIZE) {
      SDL_Delay(10);  // キューは満タン
    } else if (!av_read_frame(fctx, &packet)) {  // フレーム読み込みに成功
      if (packet.stream_index == stream_index) {  // 音声ストリーム
        queue->push(packet);
      } else {
        av_free_packet(&packet);  // それ以外のストリームは即破棄
      }
    } else if (url_feof(fctx->pb)) {  // EOF に到達
      break;
    } else if (!url_ferror(fctx->pb)) {  // 読み込みエラー
      break;
    } else {
      SDL_Delay(100);
    }
  }
}

いくつかの変数は他スレッドと共有しています (C++ 的にはメンバー変数と思えば OK). キューの内容量が MAX_BUFFER_SIZE を超えていたら待機します. この値は適当に決めればいいと思います (packet.size などをダンプして見て決めたり). このスレッドは av_read_frame() の呼び出しとキューへのプッシュがメインです. av_read_frame() は fctx の次のパケットを取得します. ここでは音声ストリーム以外は捨てていますが, その際に av_free_packet() を呼び出さないと場合によってはメモリーリークします.

後でこのスレッドにポーズやシークなどの処理も加えていきます.

そしていよいよコールバック関数です.

// フレームをデコードする関数
int decodeFrame(Uint8* buffer, int buffer_size) {
  AVPacket* packet = ...;  // 毎回共有. パケットを受け取る場所.
  uint8_t* packet_data = ...;  // 同上. 破棄のためにデータ先頭を取っておく場所.
  int& packet_size = ...;  // 同上. 破棄のためにデータサイズを取っておく場所.
  Uint16* buffer = ...;  // 同上. バッファー先頭へのポインター.
  int& buffer_size = ...;  // 同上. バッファー全体のサイズ.
  AVCodecContext* cctx = ...;  // 先ほど取得したもの.
  PacketQueue* queue = ...;  // queueingThread() と共有.

  for (;;) {
    while (packet->size > 0) {
      av_init_packet(packet);  // data と size 以外を初期化
      int getsize = buffer_size;
      int len1 = avcodec_decode_audio3(cctx, buffer, &getsize, packet);
      if (len1 < 0) break;  // エラー. フレームをスキップする.
      packet->data += len1;
      packet->size -= len1;
      if (getsize <= 0) continue;  // 展開するフレームデータがなかった.
      return getsize;
    }
    if (packet_data) {  // 古いパケットを破棄する
      packet->data = packet_data;
      packet->size = packet_size;
      av_free_packet(packet);
      packet_data = NULL;
    }
    if (!queue->pop(packet)) {
      return -1;
    }
    packet_data = packet->data;
    packet_size = packet->size;
  }
}

// コールバック
void audioCallback(void* userdata, Uint8* stream, int len) {
  int& current_index = ...;  // 同上. バッファー上の現在位置.
  int& loaded_size = ...;  // 同上. バッファーに読み込まれているデータサイズ.
  Uint16* buffer = ...;  // 同上. decodeFrame() と同じもの.
  int volume = ...;  // ボリューム. [0, 32768] の値.
  int pan = ...;  // 真ん中を 0 としたパン. [-16384, 16384] の値.

  while (len > 0) {
    if (current_index >= loaded_size) {
      int read_size = decodeFrame();
      if (read_size >= 0) {
        loaded_size = read_size;
      } else {
        loaded_size = 1024;
        std::memset(buffer, 0, 1024);  // 無音
      }
      current_index = 0;
    }
    int len1 = (loaded_size - current_index) * sizeof(*buffer);
    if (len1 > len) len1 = len;  // 実際に出力する量

    Uint16* s = reinterpret_cast<Uint16*>(stream);
    Uint16* b = buffer;
    stream += len1;
    len -= len1;
    current_index += len1 / sizeof(*buffer);

    // 書き込み. AUDIO_S16SYS でステレオと仮定する.
    len1 /= 2 * sizeof(*s) / sizeof(*stream);  // len1 をサンプル数に変換
    if (len1 > 0) do {
      *s++ = *b++ * volume / 32768 * (16384 - pan) / 32768;  // L
      *s++ = *b++ * volume / 32768 * (pan + 16384) / 32768;  // $
    } while (--len1);
  }
}

ちょっとごちゃごちゃしてますが. 変数群はほとんどスレッド外部から userdata 経由で与えられるものです. C++ でオブジェクト指向っぽくやるなら, userdata にオブジェクトへのポインターを入れといてメンバー関数を呼び出せば, すぐにクラス内部に入り込めるので, これらはすべてメンバー変数だと思ってもよいです.

decodeFrame() はキューからパケットを取り出してデコードし, バッファーにデータを入れてその長さを返します. avcodec_decode_audio3() がデコード処理本体です (3 はバージョン番号で, この関数のインターフェイスが何度も変わっていることを示しています). 上の実装では, パケット内に複数のフレームがある場合にも対応するように, パケット取得時の data と size を保存しておいて, デコードの度に packet->data や packet->size を更新しています. そして破棄するときには最初の data と size で破棄します (こうしないと当然破棄に失敗します).

audioCallback() は decodeFrame() によってパケットをデコードして buffer に読み込み, それを stream に書き込んでいきます. その際, buffer には len によって要求されるサイズぴったりは入らないので, 必要な分読み込んだり, 読み込みすぎた分は次回にまわしたりします. len, len1 や current_index, loaded_size の処理がこれにあたります. この際, len はバイト単位なのに対して current_index や loaded_size は 2 バイト単位であることに注意します.

書き込み処理ではボリュームとパンも反映しています. ここら辺はアプリケーションごとに好きにいじればよいところです. stream に書き込む値は, 絶対値が大きければボリュームも大きくなります. 上の実装では volume == 32768 が最大で, それ以下だと値に応じてデータを縮小します. pan は -16384 に近ければ左チャンネルを大きく保ち, 16384 に近ければ右チャンネルを大きく保ちます.

これで FFmpeg+SDL による音声再生の基本は完了です.

結局 2 回でも終わってないですが. 次回は再生位置の取得やポーズ/シーク, 複数音声のミキシングを手書きする話などの予定.