Code of Poem
Unknown programmer's programming note.

ウィンドウを表示するコードの解説

SDLを使ってウィンドウを表示することができるようになりました。もしかしたら、その前に作ったHello Worldや数当てゲームに比べると難しく感じられるかもしれません。もし難しく見えたとしても、おそらくそれはSDLの関数が何をするものかまだ知らないからというだけで、プログラム構造自体は単純です。エラーチェックのための2つのifと、ウィンドウの中身を更新するための1つのwhile、その中で✘ボタンを押されたとき終了するためにイベントをチェックする2つのif、あとは上から順に処理していってるだけです。もしDirectXやOpenGLを利用するコードを書いたことがあれば、SDLの初期化コードはずっとシンプルで直感的に見えると思います。

前回のコードを載せておきます。

#include <SDL2/SDL.h>
#include <iostream>
using namespace std;

int main()
{
    if (SDL_Init(SDL_INIT_VIDEO) != 0) {
        cerr << "エラー: SDL_Init " << SDL_GetError() << '\n';
        return 1;
    }
    
    SDL_Window* window = SDL_CreateWindow(
        "Hello, SDL2!",  // ウィンドウタイトル
        0, 0,            // ウィンドウの位置 x座標とy座標
        400, 400,        // ウィンドウのサイズ 幅と高さ
        SDL_WINDOW_SHOWN // フラグ
    );
    
    if (window == nullptr) {
        cerr << "エラー: SDL_CreateWindow " << SDL_GetError() << '\n';
        return 1;
    }
    
    SDL_Surface* surface = SDL_GetWindowSurface(window);
    SDL_FillRect(surface, nullptr, SDL_MapRGB(surface->format, 0, 0, 255));
    while (1) {
        SDL_Event event;
        if (SDL_PollEvent(&event)) {
            if (event.type == SDL_QUIT) {
                break;
            }
        }
        SDL_UpdateWindowSurface(window);
        SDL_Delay(16); // 16ミリ秒待つ
    }
    SDL_DestroyWindow(window);
    SDL_Quit();
}

これをコンパイルするには

g++ -o hellosdl hellosdl.cpp -lSDL2

とします。

まず目につくのは名前が「SDL_」で始まる関数たちです。これらはみんなSDLが提供する関数です。SDLの機能を利用するための関数はすべて「SDL_」という名前で始まっています。他のライブラリやユーザーの名前と衝突しないように一貫してこのような名前の規則を使っているようです。決して技巧的ではないですが、その効果は大きくコード上ではSDLの関数を使っている部分が一目瞭然です。関数以外にも、「SDL_」で始まる型と定数がいくつか使われています。

以下に使用した関数と型と定数を一覧にして、ドキュメントへのリンクを貼っておきます。

関数

定数

こういったライブラリの機能へアクセスすらために提供される関数やデータ型などをまとめてAPI (Application Programming Interface)と呼ぶことがあります。〜ことがあります、と曖昧な言い方をしたのは、そう呼ばないこともあるだろうからです。だいたいある程度の規模になるとそう呼ばれることが多い傾向があるようです。ですが正式にAPIと呼ぶかどうかライブラリの設計者が決めることです。SDLの場合はドキュメントでAPIと呼ばれています。このAPIという呼び名は、いちいち関数と型と定数…と繰り返さなくても良いし、SDLのものなのかそうではない普通の関数や型なのか区別できて便利なのでこれからも使うことにします。

話を戻すと、上にリストアップしたAPIの数は、ただ色を塗りつぶすだけのプログラムにしては結構な数です。疑問に思うのは、

という2点です。完全に主観の入った意見を述べてみます。

一点目の疑問に対する提案は、

というものです。

理解するというのはプログラミングの習熟度によって大きく差が出てきます。素晴らしいことにSDLはソースコードが公開されていて、望めばいつでも該当するAPIの実際のコードを調べることが出来ます。ゲームを作るという目的に囚われすぎるとこの点を見過ごしてしまうこともあるのですが、これは大きいです。現実に使われているソースコードを見ることできるということはプログラミングの学習教材としてとても適しています。とはいえ、最初のうちからソースコードをみて理解するまで次に進まない、というやり方ではいつまでも先に進めないので、まずは本来の利用目的、ゲームを作るという沿った範囲でドキュメントとヘッダファイルを見て利用方法を調べられるくらいで良いのではないかと思います。

二点目の疑問に対してはあまりいい提案が出来ません。

カテゴリに分類されたAPIの一覧はサポートされる機能の全体像を把握するのに便利です。しかしこれだけ見ても作りたいものをすぐに作れるようにはなっていない場合が多いです。例えば、好きな場所に好きな色のピクセルを置く関数が知りたいと思って探し始めても、それは用意されていないことに気づきます。そうすると、何とかAPIの名前からピクセルに関連しそうなものを探し出さなければならず、あちこちクリックして徘徊すると、先のプログラムでも使った SDL_Surfaceという型にpixelsというフィールドが含まれていて、ドキュメントには次のように書かれていました。

void* pixels | the pointer to the actual pixel data; see Remarks for details (read-write)

void* pixels | 実際のピクセルデータへのポインタ。詳細はRemarksを参照

Remarks | With most surfaces you can access the pixels directly. Surfaces that have been optimized with SDL_SetSurfaceRLE() should be locked with SDL_LockSurface() before accessing pixels. When you are done you should call SDL_UnlockSurface() before blitting.

Remarks | ほとんどのサーフェイスについて、直接ピクセルにアクセスすることができる。SDL_SetSurfaceRLE()で最適化されたサーフェイスは、ピクセルにアクセスする前にSDL_LockSurface()でロックされる必要がある。やることを終えたらビットマップを転送する前にSDL_UnlockSurface()を呼び出す必要がある。

これを読むとピクセルデータにアクセスできるから自由にピクセルをセットできるような気がしてきますが、正直何をしたらいいかいまいち分かりません。また、SDL_RenderDrawPointという関数も見つかります。これは比較的分かりやすいです。しかし、どうするのが正しいのか分からず、結局ググってしまいました。公式のリファレンスとソースコードを調べることですべてが解決するというのは理想論で、現実を見るとググらないとどうしようもないこともたくさんあると思います。

コードの解説

1行ずつ見ていっても分かりづらいだけなので、まとまりごと区切って見ていきます。

必要なヘッダのインクルード

冒頭部分です。SDL.hとiostreamをインクルードしています。

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

基本的なSDLのAPIはSDL.hというヘッダファイルで多く宣言されています。中にはSDL.hからはインクルードできず、別にインクルードをしないといけない場合もあります。注意して見るのは、SDL2/SDL.hとパスを指定しているところです。Debianのaptでインストールした場合、SDL.hを始めとするSDL関連のヘッダは /usr/include/SDL2 というディレクトリ以下に配置されるようになっています。/usr/includeはGCCがヘッダファイルを検索するパスとしてデフォルトで認識するようになっているので、そこからのパスを指定することで、サブディレクトリにあるヘッダファイルも見つけ出すことができるようになってます。このSDL2/SDL.hというパスはシステムによって異なるし、ソースからビルドしたときも異なることになります。

iostreamはstd::cerrのためにインクルードしています。SDLにはSDL_Logという関数が用意されていて、単にメッセージを出力するだけならこちらを使うことも出来ます。もしそうするなら今回はiostreamをインクルードする必要もありませんでした。そっちの方がシンプルでよかったかもしれません。

SDLの初期化

main関数の中をみていきます。最初はSDL_Init関数を呼び出してSDLを初期化しています。

if (SDL_Init(SDL_INIT_VIDEO) != 0) {
    cerr << "エラー: SDL_Init " << SDL_GetError() << '\n';
    return 1;
}

「SDLを初期化する」と言ってもここでSDLというのは何を指すのか分かりづらいかもしれません。ドキュメントではsubsystemと記述されています。分かりやすいように今後「サブシステム」と呼ぶことにします。サブシステムを初期化するとは、これから使うSDLの機能を総まとめしている何かを使える状態にすることといった感じでしょうか。SDL_Initの引数であるSDL_INIT_VIDEOという定数はサブシステムのビデオに関連する機能を初期化することを指示しています。ドキュメントを見てもらえれば分かるように、ビデオ以外にも、例えばオーディオを使えるようにするには SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)のようにして呼び出します。ここで「|」はビット演算のORで、SDL_INIT_XXXはビットのORをとったとき、それぞれのビットが被らないように2のべき乗の値に調整されて定義されています。これらはフラグと呼ばれています。例えば、フラグAの値が2で、フラグBの値が4だとすると、二進数表記でAは0000010、Bは00000100、この2つのORを取ると00000110になります。SLD_Initはこのうち1になっている部分を初期化するように指示されたと解釈できるので、指示されたサブシステムのみの初期化を行うことになります。

SDL_Init関数の結果を0と比較しているのは戻り値に関してそういう決まりがあるからです。ドキュメントには次のように書かれています。

Return Value

Returns 0 on success or a negative error code on failure; call SDL_GetError() for more information.

成功したら0を返すし、失敗したら負のエラーコードを返す。より多くの情報を得るにはSDL_GetError()を呼び出す。

ということで、初期化のコードは戻り値が0であるかチェックして、もし0でなかったらサブシステムの初期化に何らかの理由で失敗しているので、SDL_GetError関数から得られる情報をエラーメッセージとして出力し、プログラムを終了してしまいます。このプログラムではSDLが使えなければもうできることは何もないので終了してしまう以外手がなさそうです。

SDL_Init関数が0を返せばサブシステムの初期化に成功したので次のステップに進みます。

ウィンドウを作る

サブシステムの初期化に成功したらすぐにウィンドウを作って表示できる状態になります。ウィンドウの作成にはその名前から分かるとおり、SDL_CreateWindow関数を使います。

SDL_Window* window = SDL_CreateWindow(
    "Hello, SDL2!",  // ウィンドウタイトル
    0, 0,            // ウィンドウの位置 x座標とy座標
    400, 400,        // ウィンドウのサイズ 幅と高さ
    SDL_WINDOW_SHOWN // フラグ
);

if (window == nullptr) {
    cerr << "エラー: SDL_CreateWindow " << SDL_GetError() << '\n';
    return 1;
}

この関数はウィンドウを作成してすぐにそのウィンドウを表示した状態にします。引数の数が6つとやや多いですが、それぞれ役割については紛らわしいところはないと思います。コメントに書いた文だけでも十分だと思います。最後のフラグについてだけは分かりづらいかもしれません。ドキュメントには他にもいくつか指定できるフラグがあるようです。SDL_Init関数でしてしたのと同じようにビットORで複数指定できるようです。ここで指定しているSDL_WINDOW_SHOWNについては次のように書かれています。

SDL_WINDOW_SHOWN is ignored by SDL_CreateWindow(). The SDL_Window is implicitly shown if SDL_WINDOW_HIDDEN is not set. SDL_WINDOW_SHOWN may be queried later using SDL_GetWindowFlags().

SDL_CreateWindow()はSDL_WINDOW_SHOWNを無視する。もしSDL_WINDOW_HIDDENがセットされていなければ、SDL_Windowは暗黙に表示される。SDL_WINDOW_SHOWNはSDL_GetWindowFlags()を使ってあとで問い合わせることがある。

なので、SDL_WINDOW_SHOWNの代わりに0を指定することもできるようです。つまり、先のコードは SDL_Window* window = SDL_CreateWindow("Hello, SDL2!", 0, 0, 400, 400, 0);と書くことも出来たわけです。

SDL_CreateWindow関数の戻り値を変数windowにセットしています。ここで重要な2つのC++の機能(SDLはCで書かれているので正しくはCの機能です)が使われています。一つは構造体でユーザー定義型です。もう一つは ポインタです。詳しくは別のところに書きたいと思います。今の所はSDL_Windowという型の値のアドレス(参照と言い換えたほうが分かりやすいかもしれません)を保持する変数がwindowであるということだけ書いておきます。このwindowが何をするものなのか分からなくても、直接使うことはなさそうなので大丈夫そうです。windowに関連する何かを行うときは、API関数の引数としてこのwindow渡すことによって行うことになります。

次のwindow == nullptrのところはエラーチェックです。もしSDL_CreateWindowがウィンドウの作成に失敗したとき、何も指していないポインタ、nullptr(SDLはCで書かれているのでCの場合はNULLに相当します)を返すことになっています。したがってwindowがnullptrと等しいかどうかをチェックすることでウィンドウの作成に失敗したかどうかを確認することが出来ます。失敗していたらやはりSDL_GetError()で情報をエラーメッセージとして出力し、終了してしまいます。

ウィンドウの中を色で塗りつぶす

SDL_CreateWindowが成功した時点で、ウィンドウは表示されています。しかし、そのままでは中身が透明の虚しい結果しか得られないので、なにか色で塗りつぶして、ちゃんと機能しているということを確認しておきます。

SDL_Surface* surface = SDL_GetWindowSurface(window);
SDL_FillRect(surface, nullptr, SDL_MapRGB(surface->format, 0, 0, 255));
while (1) {
    SDL_Event event;
    if (SDL_PollEvent(&event)) {
        if (event.type == SDL_QUIT) {
            break;
        }
    }
    SDL_UpdateWindowSurface(window);
    SDL_Delay(16); // 16ミリ秒待つ
}

作成したウィンドウをSDL_Window* windowという変数に保持させるようにしたのと同じように、今度はウィンドウのサーフェイスを取得して、それをSDL_Surface* surfaceという変数で保持するようにしています。上記のコードではSDL_Surface* surface = SDL_GetWindowSurface(window);のところがそれを行っています。

SDL_Surfaceは次のように説明されています。

A structure that contains a collection of pixels used in software blitting.

ソフトウェアのビットマップ転送で使われるピクセルのコレクションを保持する構造体。

SDL_Surface

また、SDL_GetWindowSurface関数は次のとおりです。

Use this function to get the SDL surface associated with the window.

ウィンドウに関連付けられたSDLのサーフェイスを取得するにはこの関数を使用する。

SDL_GetWidnowSurface

先にSDL_CreateWindow関数で作成したウィンドウに関連付けられたサーフェイスを取得しています。そのサーフェイスはウィンドウの描画領域に関する情報を持っていて、直接ピクセルにアクセスして色を塗ることもできるし、すぐ下でやっているように、API関数を使って何かを描くこともできるようになります。

取得したサーフェイスを使って、 SDL_FillRect(surface, nullptr, SDL_MapRGB(surface->format, 0, 0, 255));の一文でサーフェイス全体を青で塗り潰しています。この一文ではSDL_FillRectとSDL_MapRGBの2つのAPI関数が使われています。なのでややこしく見えるかもしれません。一つずつ見ていけばそんなに難しくないです。

まずSDL_FillRect関数は、指定されたサーフェイスの指定された矩形領域を指定された色で塗りつぶします。それぞれ第1引数、第2引数、第3引数に指定します。ここで第1引数は当然さっき取得したwindowのサーフェイスです。第2引数にnullptrを渡していますが、ここをnullptrにするとサーフェイス全体を塗りつぶすという指定になるようになっています。今はそうしてほしいのでnullptrを渡しています。問題は第3引数です。ここは色を指定するのですが、注意があります。

color should be a pixel of the format used by the surface, and can be generated by SDL_MapRGB() or SDL_MapRGBA(). If the color value contains an alpha component then the destination is simply filled with that alpha information, no blending takes place.

color (第3引数のこと)はサーフェイスが使っているフォーマットのピクセルにする必要があり、SDL_MapRGB()あるいはSLD_MapRGBA()で生成することができる。もしcolorの値がアルファ成分を含んでいるならば、そのアルファ情報を含めて転送先を単純に塗りつぶす。

SDL_FillRect

これは、例えばCSSで指定するような16進値の色指定#0000ffのようなものだけではダメで、単純に色を指定するだけでなく、サーフェイスが使っている何らかのフォーマットに従った形式で指定しなければいけないということを言ってます。そのような色はSDL_MapRGB関数で生成できるとも言ってます。この第3引数のcolorの型はUint32となっていて、これは符号なし32ビットの整数です。SDL_MapRGB関数に渡している色の値は赤緑青の順で、0、0、255としています。そしてSDL_MapRGB関数の第1引数はsurface->formatとしています。これらのことから推測すると、SDL_MapRGB関数は、指定された赤青緑それぞれ0〜255の値を、指定されたフォーマット(ここではサーフェイスのフォーマット)情報に従って、符号なし32ビットの整数に変換してくれる関数だということが考えられます。SDL_MapRGB関数のドキュメントにもそう書かれています。

Use this function to map an RGB triple to an opaque pixel value for a given pixel format.

RGBの3つを、与えられたピクセルフォーマットの不透明なピクセル値にマップするにはこの関数を使う。

This function maps the RGB color value to the specified pixel format and returns the pixel value best approximating the given RGB color value for the given pixel format.

この関数はRGBの色の値を指定されたピクセルフォーマットにマップして、与えらたピクセルフォーマットに与えられたRGBの色が最も良く近似するピクセル値を返す。

If the format has a palette (8-bit) the index of the closest matching color in the palette will be returned.

もしフォーマットが8ビットのパレットを持つなら、パレットの中で最も近い色のインデックスを返す。

If the specified pixel format has an alpha component it will be returned as all 1 bits (fully opaque).

もし指定されたピクセルフォーマットがアルファ成分を持つなら、アルファ値のビットは全て1で、完全に不透明として返す。

If the pixel format bpp (color depth) is less than 32-bpp then the unused upper bits of the return value can safely be ignored (e.g., with a 16-bpp format the return value can be assigned to a Uint16, and similarly a Uint8 for an 8-bpp format).

もしピクセルフォーマットのbpp (Bits Per Pixel; 色深度)が32-bppより少ない場合、返り値の使用していない上位のビットは安全に無視することができる。(例: 16-bppのフォーマットでは、返り値はUnit16に代入することができて、同様に8-bppのフォーマットではUint8に代入することができる。)

SDL_MapRGB

もし分かりにくかったら、この部分は2行に分けて次のようにすることも出来ます。

Uint32 color = SDL_MapRGB(surface->format, 0, 0, 255);
SDL_FillRect(surface, nullptr, color);

次はwhileループです。while (1)while (true)と書くことも出来ます。このwhileの中身で特に重要なのは、SDL_UpdateWindowSurface関数の呼び出しです。上でSDL_FillRect関数を使ってウィンドウのサーフェイスを塗りつぶしましたが、まだ画面には反映されていません。サーフェイスに何かを書き込んだらすぐ反映するようにしてしまうと、サーフェイスの一部分を何度かに分けて塗りつぶすとかしたとき、画面がちらついてしまうので、それを避けるために、書き込みは行うが実際に画面に反映させるのは一度にまとめてやるという手法が取られているからです。これはよくあるダブルバッファリングと呼ばれるものだと思われます。

自分の環境ではなぜかSDL_UpdateWindowSurface関数を1回呼び出しただけでは塗りつぶしが反映されませんでした。この原因が何なのかすごく気になります…その回避策としてwhileループで囲んで何度も塗りつぶすようにしてあります。このようにするとちゃんと塗りつぶされたことが確認出来ます。

SDL_PollEvent関数のところはのイベント処理です。SDL_Event event;の変数の定義は、前のSDL_Window*と違いポインタではありません。ユーザー定義型もintなどの組み込み型のように、ポインタではなく値として定義することが出来ます。SDL_Eventはちょっと不思議な型で共用体と呼ばれるものです。SDL_Eventという一つの型で複数の型を表現することが出来ます。どのような型になるかはSDL_PollEvent関数の結果しだいで、結果どのような型になっているかはtypeフィールドの値によって判定することが出来ます。ここではtypeがSDL_QUITであれば、SDL_QuitEventであり、SDL_QuitEventが発生したということはプログラムを終了させるイベント、ウィンドウの✘ボタンがクリックされたなどしたので、whileループを抜けてプログラム終了へと進みます。

SDL_Delay関数は引数で指定された時間だけ待ちます。指定する時間はミリ秒単位です。この関数を挟まずにwhileループを走らせてしまうとwhileは全力でループしてCPUリソースを消費してしまうかもしれないので入れてあります。

あとは終了のための処理です。

SDL_DestroyWindow(window);
SDL_Quit();

SDL_DestroyWindow関数は指定されたウィンドウを破棄します。これはSDL_CreateWindow関数で作成されたウィンドウを引数に渡します。

SDL_Quit関数はSDL_Initで初期化されたサブシステムの片付けを行います。