DDSファイルを自力で読んでみよう

  • みなさん初めまして。セガゲームス開発技術部の内田です。

普段は、ゲーム開発に利用するツールやライブラリなどを作成する仕事をしています。
今回は、そのツール開発で必要になった機能を1つ取り上げ、その実装についてお話してみたいと思います。

今回のお題:DDSファイルを自力で読んでみよう

DDSファイルと言えば、ゲームなどのグラフィックスプログラムを作成するときによく利用される画像ファイルフォーマットです。
これは、JPEGやPNGといった画像フォーマットの1つなのですが、DirectXなどで簡単かつ高速に扱えるという利点があります。
DirectXに用意されている機能を利用すれば、簡単にテクスチャとして読み込み、ポリゴンに貼り付けたりすることができます。

そのため、読み込みよりも、どのように高品質のDDSファイルを作成するかが重要な点であり、そのために多くの優秀なソフトウェアや圧縮のノウハウが開発、考案されてきたと言えます。

ですが、今回はDDSへの変換ではなく、読み込みを、ゼロから書いたコードで、自力でやってみましょう。
これができるようになると、

  • ピクセルデータを展開してRGBAの情報を直接利用したい。
  • DirectXやOpenGL、その他ライブラリなどを利用したくない、できない。
  • マネージコードだけで読み込みたい。
  • 何らかのツールのインポートプラグインを書きたい。
  • 他の画像形式に変換したい。
  • 画像の情報(例えば、横幅や高さ、圧縮形式)を取得したい。

などどいった場合に役立つかもしれません。


今回は以下の条件のもと、進めていきたいと思います。

  • 使用言語はC#(なるべく新しい Visual Studio)
  • DDSファイルを読み込み、System.Drawing.Bitmap クラスに変換(WinFormsアプリケーションを想定)
  • DXT1形式のみ対応(最後にBC1にも対応)
  • ミップマップには対応しない

また、処理を簡潔にわかりやすくするため、エラー処理や例外処理は最低限にしたいと思います。

今回はDDSの中でもDXT1形式というレガシーな形式のみを取り上げますが、自分の書いたコードだけで読めるようになるというのは面白いのではないでしょうか。

それでは、さっそく始めましょう。

目次

テストプログラムの作成

いきなりDDSファイルの読み込み部分を作成、、、といきたいところですが、実際には、ファイルを読み込ませながら、少しずつデバッグし、完成させていくことなります。
そこで、まずはドラッグ&ドロップで簡単にDDSファイルを読み込み表示できるようなテストプログラム(System.Windows.Forms 利用)を作成してしまいます。

f:id:sgtech:20161222105257p:plain

フォームデザイナでPictureBoxを追加した後、以下のようにドラッグ&ドロップのコードを書きました。

public Form1()
{
    InitializeComponent();

    AllowDrop = true;
    DragEnter += (sdr, ea) => ea.Effect = DragDropEffects.All;
    DragDrop += (sdr, ea) => {
        var fname = ((string[])(ea.Data.GetData(DataFormats.FileDrop))).FirstOrDefault();
        pictureBox1.Image = DdsLoader.LoadFromFile(fname);
    };
}


ですが実際のところ、開発中は何度も何度も同じ画像を読み込むことになるので、次のようにしてしまった方が楽かもしれません。

protected override void OnShown(EventArgs e)
{
    base.OnShown(e);
    pictureBox1.Image = DdsLoader.LoadFromFile(デバッグに使用するDDSファイルのフルパス);
}

そして、ここから

public static class DdsLoader
{
    public static System.Drawing.Bitmap LoadFromFile(string filename)
    {
    }
}

の中身をを少しづつ実装していきます。

DDSファイルヘッダのすごく簡単な構造

DDSファイルのヘッダは128バイトもあり、複雑な構造となっているのですが、今回は、ごく簡単なパラメータのみの取得で十分です。
必要となるのは、赤枠で示された4ヶ所だけです。

f:id:sgtech:20161222105213p:plain

ヘッダ情報を格納するクラスを作る

まずはDDSファイルのヘッダから必要な情報を抜き出してまとめるためのクラスを作りましょう。例えばこのようになります。

public class DdsSimpleFileInfo
{
    /// <summary>
    /// この情報が有効であることを示す値を取得または設定します。
    /// </summary>
    public bool IsValid { get; set; }

    /// <summary>
    /// 画像の横幅を取得または設定します。
    /// </summary>
    public int Width { get; set; }

    /// <summary>
    /// 画像の高さを取得または設定します。
    /// </summary>
    public int Height { get; set; }

    /// <summary>
    /// ピクセルデータの開始位置を取得または設定します。
    /// </summary>
    public int PixelOffset { get; set; }

    /// <summary>
    /// ファイル形式を示す文字列を取得または設定します。
    /// </summary>
    public string Format { get; set; };
}

ヘッダを解析する

解析を始める前に、まずは必要となるメソッドをいくつか実装していきましょう。ここでは、BinaryUtility というクラスにまとめました。
これは、標準で用意されている BitConverter.ToInt32() や BitConverter.ToInt16()で、リトルエンディアン環境では十分なのですが、自作してしまいましょう。

public static class BinaryUtility
{
    /// <summary>
    /// 指定したバイト列のオフセット位置に格納されている、32ビット無符号整数を取得します。
    /// </summary>
    /// <param name="bytes"></param>
    /// <param name="offset"></param>
    /// <returns></returns>
    public static uint MakeUInt32(byte[] bytes, int offset)
    {
        return MakeUInt32(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
    }

    public static uint MakeUInt32(byte a, byte b, byte c, byte d)
    {
        return (uint)((a) | ((b) << 8) | ((c) << 16) | ((d) << 24));
    }

    /// <summary>
    /// 指定したバイト列のオフセット位置に格納されている、16ビット無符号整数を取得します。
    /// </summary>
    /// <param name="bytes"></param>
    /// <param name="offset"></param>
    /// <returns></returns>
    public static ushort MakeUInt16(byte[] bytes, int offset)
    {
        return (ushort)((bytes[offset]) | ((bytes[offset + 1]) << 8));
    }
}

次のようなメソッドを作成し、読み込んだバイト列から 先ほど定義した DdsSimpleFileInfo クラスに内容を取得します。
先にも書いたように、本来であれば複雑なフラグ類をたくさん解析しないといけないのですが、今回はすごく簡略化していますので、やることは次の4つだけとなります。

  • ヘッダの先頭に "DDS{スペース}" という文字列があるかどうかを調べる
  • 横幅と高さを取得する
  • 圧縮形式が "DXT1" かどうかを調べる
  • ピクセルデータ開始位置を取得する


失敗しても null を返さずに、IsValidがfalseとなっている DdsSimpleFileInfo インスタンスを返すようにしておいたほうが使い勝手が良いでしょう。

以上をまとめてメソッドを作成すると、次のようになるでしょうか。

public static class DdsLoader
{
    public static DdsSimpleFileInfo GetDdsInfo(byte[] data)
    {
        const int HeaderSize = 0x80;

        var info = new DdsSimpleFileInfo();    // 情報クラスを作成

        if (data.Length < HeaderSize) {
            return info;        // DDSとしてのヘッダサイズが足りない
        }

        var magic = Encoding.UTF8.GetString(data, 0, 4);        // data 配列の先頭から連続する4バイトを文字列に変換
        if (magic != "DDS ") {
            return info;        // DDSファイルではない
        }

        // 画像の横幅と高さ
        info.Height = (int)BinaryUtility.MakeUInt32(data, 0x0c);        // data 配列の 12バイト目から連続する4バイトをInt32に変換
        info.Width = (int)BinaryUtility.MakeUInt32(data, 0x10);         // data 配列の 16バイト目から連続する4バイトをInt32に変換

        info.PixelOffset = HeaderSize;        // ピクセルデータの開始位置

        // DXT1形式かどうかをチェック
        info.Format = Encoding.UTF8.GetString(data, 0x54, 4);
        switch (info.Format) {
            case "DXT1":
                break;
            default:
                return info;
        }

        info.IsValid = true;            // ヘッダ取得成功を示すフラグを設定

        return info;
    }
}

さて、ヘッダ情報が解析できるようになったので、それを使ったファイル読み込みのメソッドは次のようになります。

public static class DdsLoader
{
    public static System.Drawing.Bitmap LoadFromFile(string filename)
    {
        // ファイルをバイト列に読み込む
        byte[] data = System.IO.File.ReadAllBytes(filename);

        // ヘッダを解析
        var info = GetDdsInfo(data);
        if (info.IsValid) {
            switch (info.Format) {
                case "DXT1":
                    // DXT1形式なら解凍してBitmapに変換
                    return DXT1Decoder.CreateBitmap(data, info.Width, info.Height, info.PixelOffset);
            }
        }

        return null;
    }
}

ここからはDXT1形式で格納されているピクセルデータを展開していきましょう。

DXT1形式をデコードする

ブロックの構成

DXT1形式では横4ピクセルx縦4ピクセルの情報を圧縮、格納した「ブロック」から構成されています。
1ブロックは必ず8バイトで、次のようになっています。

f:id:sgtech:20161222105215p:plain

いくつのブロックが入っているの?

1ブロックで4x4ピクセルが構成されますが、たとえば1x3や2x2の画像の場合においても、1ブロックが使われることになっています。
つまり、縦、横それぞれのピクセル数を4で割って、余りがあればさらに1ブロック余計に必要になるということになります。

通常、テクスチャとして使用される画像は、512x512 や 2048x1024 などどいった、2のn乗サイズが基本ですので、4の倍数でないということはほとんどないのですが、将来的にミップマップに対応することを考えると、2x2 や 2x1といったサイズも最初から考慮してあげた方が良いでしょう。


まずは、ピクセル数から必要なブロック数を計算する処理をメソッドにしておきましょう。

public static class DXTUtility
{
    /// <summary>
    /// 指定されたピクセルサイズを格納するために必要なブロックの個数を取得します。
    /// </summary>
    /// <param name="pixelSize">ピクセル数</param>
    /// <returns></returns>
    public static int GetBlockCount(int pixelSize)
    {
        return (pixelSize >> 2) + (((pixelSize & 3) != 0) ? 1 : 0);
    }
}

さらに、画像全体でいくつのブロックが格納されているかを調べて、すべて列挙するようなメソッドを作成します。

    public struct BlockInfo
    {
        public int X;        // ピクセル位置X
        public int Y;        // ピクセル位置Y
        public int Offset;    // ブロックデータオフセット

        public BlockInfo(int x, int y, int offset)
        {
            X = x;
            Y = y;
            Offset = offset;
        }
    }

public static class DXTUtility
{
    /// <summary>
    /// 指定された横幅、高さの画像のブロック情報をすべて列挙します。
    /// </summary>
    /// <param name="width">横幅</param>
    /// <param name="height">高さ</param>
    /// <param name="dataOffset">ファイル中のブロック先頭オフセット</param>
    /// <param name="blockSize">1ブロックのバイト数</param>
    /// <returns></returns>
    public static IEnumerable<BlockInfo> EnumerateBlocks(int width, int height, int dataOffset, int blockSize)
    {
        int horizontalBlockCount = GetBlockCount(width);
        int verticalBlockCount = GetBlockCount(height);

        int offset = dataOffset;
        for (int i = 0; i < verticalBlockCount; i++) {
            for (int j = 0; j < horizontalBlockCount; j++) {
                yield return new BlockInfo(j * 4, i * 4, offset);
                offset += blockSize;
            }
        }
    }
}
デコード開始

さてブロックが列挙できるようになったので、デコード機能を徐々に作成していきましょう。
このような仕様のメソッドにしておきましょうか。
DdsSimpleFileInfo クラスを引数に取らず、width, height, pixelOffset に分けて渡す仕様にしているのは、将来的にミップマップに対応する布石とでも考えておいてください。

public static class DXT1Decoder
{
    public static System.Drawing.Bitmap CreateBitmap(byte[] data, int width, int height, int pixelOffset)
    {
    }
}

まずは縦横それぞれのブロック数を取得し、それに4を掛けて、物理的なピクセル数を計算し、展開先のバッファを用意します。
展開先のバッファサイズは、
横物理ピクセル数 x 縦物理ピクセル数 x 4バイト (RGBA)
となります。

その後、すべてのブロックを処理するようループを記述します。

    int BlockSize = 8;

    int physicalWidth = DXTUtility.GetBlockCount(width) * 4;
    int physicalHeight = DXTUtility.GetBlockCount(height) * 4;

    var pixels = new byte[physicalWidth * physicalHeight * 4];

    var blocks = DXTUtility.EnumerateBlocks(width, height, pixelOffset, BlockSize);

    foreach (var block in blocks) {
        // ここで block をデコードし pixel 配列の該当位置にRGBAデータを展開する
    }
カラーの取り扱い


DXT1ブロックの色情報は、 RGB成分がそれぞれ5ビット、6ビット、5ビットとして16ビットにパックされています。
この状態から色の計算を簡潔に記述するため、次のような構造体を作成しておきましょう。

public struct ColorRgba
{
    public float R; // 赤成分(0.0~1.0)
    public float G; // 緑成分(0.0~1.0)
    public float B; // 青成分(0.0~1.0)
    public float A; // アルファ成分(0.0~1.0)

    /// <summary>
    /// ColorRgba構造体の新しいインスタンスを初期化します。
    /// </summary>
    public ColorRgba(float r, float g, float b, float a)
    {
        R = r;
        G = g;
        B = b;
        A = a;
    }

    /// <summary>
    /// RGB565形式の16ビットカラーデータから ColorRgbaインスタンスを生成します。
    /// </summary>
    public static ColorRgba FromRgb565(ushort rgb565)
    {
        int r = (rgb565 >> 11) & 0x1f;
        int g = (rgb565 >> 5) & 0x3f;
        int b = (rgb565 >> 0) & 0x1f;
        return new ColorRgba(r * 1.0f / 31.0f, g * 1.0f / 63.0f, b * 1.0f / 31.0f, 1.0f);
    }

    /// <summary>
    /// 指定された2つのカラーをリニア補間して中間色を求めます。
    /// </summary>
    public static ColorRgba Lerp(ColorRgba x, ColorRgba y, float s)
    {
        float r = x.R + s * (y.R - x.R);
        float g = x.G + s * (y.G - x.G);
        float b = x.B + s * (y.B - x.B);
        float a = x.A + s * (y.A - x.A);
        return new ColorRgba(r, g, b, a);
    }
}
1ブロックのデコード

まずはブロックの先頭2バイトからカラー0,次の2バイトからカラー1を読み込みます。

    ushort color0 = BinaryUtility.MakeUInt16(data, block.Offset);
    ushort color1 = BinaryUtility.MakeUInt16(data, block.Offset + 2);

この color0 と color1 から、4x4のブロックの各ピクセルの色を計算で求めていきます。

4x4の各ブロックの色は2ビットのインデックスとなっています。インデックスが2ビットということは0,1,2,3のいずれか、つまり、4色の中からどれか1色を選んで、そのピクセルの色を決めるということになります。
そこで、あとでインデックスで引きやすいよう、4色を格納するための配列を作成します。

    var colors = new ColorRgba[4];
アルファの判定と4色の求め方

DXT1のブロックには、いわゆる1ビットアルファ(パンチスルー)の情報を格納することもできます。
color0 と color1 を16ビット無符号整数として単純に大小を比較し、どちらが大きいかによって、

  • 4色すべてを色情報として扱う
  • 3色の色情報+透明

のどちらかを選んで格納することができます。
4色の場合は、カラー0, カラー1, そしてそれらの中間色を2色、
3色+透明の場合は、カラー0,カラー1, 中間色1色、透明、を求めます。

次のようなコードでそれを判定、計算し、カラーテーブルの内容を求めています。

if (color0 > color1) {
    // アルファなし。使える色は4色
    colors[0] = ColorRgba.FromRgb565(color0);                            // カラー0そのまま
    colors[1] = ColorRgba.FromRgb565(color1);                            // カラー1そのまま
    colors[2] = ColorRgba.Lerp(colors[0], colors[1], 1.0f / 3.0f);       // color_2 = 2/3*color_0 + 1/3*color_1
    colors[3] = ColorRgba.Lerp(colors[0], colors[1], 2.0f / 3.0f);       // color_3 = 1/3*color_0 + 2/3*color_1
} else {
    // アルファあり。使える色は3色
    colors[0] = ColorRgba.FromRgb565(color0);                            // カラー0そのまま
    colors[1] = ColorRgba.FromRgb565(color1);                            // カラー1そのまま
    colors[2] = ColorRgba.Lerp(colors[0], colors[1], 1.0f / 2.0f);       // color_2 = 1/2*color_0 + 1/2*color_1
    colors[3] = new ColorRgba(0, 0, 0, 0);                               // 透明
}
インデックスの展開

4色のテーブルの内容が決まりましたので、ここからは 4x4の各ピクセルにその内容を書き込んできます。
インデックスの情報は2ビットx16ピクセル分ということで、32ビットに収まりますので、uintの変数に読み込んでしまいましょう。
そして、そこから下位2ビットずつ取り出し、4色のテーブルの色を参照していきます。
ピクセルデータ書き込み先には、0~255の形でB,G,R,Aの順でカラーを格納していきます。

uint indexBits = BinaryUtility.MakeUInt32(data, block.Offset + 4);

for (int y = 0; y < 4; y++) {
    for (int x = 0; x < 4; x++) {
        var idx = indexBits & 0x03;                   // インデックスの取り出し
        var col = colors[idx];                        // カラーテーブルからインデックスでカラーを取り出す
        int xx = block.X + x;
        int yy = block.Y + y;
        int p = (xx + yy * physicalWidth) * 4;        // ピクセルの書き込み位置
        pixels[p + 0] = (byte)(col.B * 255.0f);       // 青(0~255)
        pixels[p + 1] = (byte)(col.G * 255.0f);       // 緑(0~255)
        pixels[p + 2] = (byte)(col.R * 255.0f);       // 赤(0~255)
        pixels[p + 3] = (byte)(col.A * 255.0f);       // アルファ(0~255)
        indexBits >>= 2;                              // インデックスを2ビット右シフトして次に備える
    }
}

ここまでの処理で、byte[] pixels という配列にピクセルデータが展開できました。
あとはこれをSystem.Drawing.Bitmapにしてあげれば、WinFormのアプリで取り扱いやすくなりますね。

System.Drawing.Bitmap のコンストラクタには、何と都合の良いことに「ストライド値」を指定できるバージョンがあります。
ストライド値とは一般的に、「次のデータは何バイト先から始まるか」ということを示すために使われる値のことを示します。
今回の場合、「次のピクセル行は何バイト先に格納されているか」あるいは「1行あたりのバイト数はいくつか」という意味になります。
画像の横幅が4の倍数でない場合、例えば5ピクセルだったとすると、作成したいビットマップは横幅5ピクセルですが、展開済みの byte[] 配列には
横8ピクセル分のデータが詰まっていることになります。
そこで、このストライド値を使用して、「次の行は 8 x 4 = 32バイト先から始まるよ」」ということを指定してあげます。

今回の場合、 physicalWidth * 4 を指定すれば良いでしょう。

unsafe {
    fixed (byte* ptr = &pixels[0]) {
        var bmp = new System.Drawing.Bitmap(width, height, physicalWidth * 4, PixelFormat.Format32bppArgb, new IntPtr(ptr));
        return bmp;
    }
}

ストライド値を使用したコンストラクタを使用するには、 byte[] 配列を IntPtr というポインタの形にして渡さなければなりませんので、unsafeコードが使えるようにコンパイラオプションを変更します。

f:id:sgtech:20161222105256p:plain

こうして得られた System.Drawing.Bitmap インタンスは、そのままPictureBoxなどに表示することができます。

テストしてみましょう

DDSファイルを何度かドロップしていると、何やら例外が発生してしまいました。

f:id:sgtech:20161222105214p:plain
f:id:sgtech:20161222105216p:plain

どうやら、上記の方法で作成したBitmapは、うまく取り扱えない場合があるようです。
ネットで検索したところ、fixed で固定したポインタを使用して作成したBitmapは、そのメモリがガベージコレクタによって移動されないことが前提となっているようです。
ストライド値が指定でき便利だったのですが、このままでは普通に使うことができず不便なので、ひと工夫しましょう。

簡単に解決する方法の1つは、fixed スコープから出ないうちに、別のビットマップにコピーしてしまうことです。
これで例外は起きなくなりました。

がしかし、そもそも作成したいビットマップの幅とストライドが同じ場合は、ストライドを指定するバージョンのコンストラクタを使用する必要はないわけですので、それを判定し、ごく一般的な方法でビットマップを作成します。
処理が少し複雑になりますので、メソッドにしてしまいましょう。

public static Bitmap CreateBitmap(byte[] pixels, int width, int height, int physicalWidth)
{
    if (width == physicalWidth) {
        // 作成したいビットマップの幅とストライドが同じ場合
        var bmp = new Bitmap(width, height, PixelFormat.Format32bppArgb);
        var bd = bmp.LockBits(new Rectangle(0, 0, width, height), ImageLockMode.WriteOnly, bmp.PixelFormat);
        System.Runtime.InteropServices.Marshal.Copy(pixels, 0, bd.Scan0, physicalWidth * 4 * height);
        bmp.UnlockBits(bd);
        return bmp;
    } else {
        // 作成したいビットマップの幅とストライドが異なる場合
        unsafe
        {
            fixed (byte* ptr = &pixels[0]) {
                // アンマネージドポインタを利用して作成したBitmapは fixed スコープ外に出るとAccess Violationを引き起こす
                // そこで、別のBitmapにコピーしてそれを返す。元のBitmapは不要なので usingを使用してDisposeする。
                using (var tmpbmp = new Bitmap(width, height, physicalWidth * 4, PixelFormat.Format32bppArgb, new IntPtr(ptr))) {
                    var bmp = new Bitmap(tmpbmp);
                    return bmp;
                }
            }
        }
    }

さて、これでいちおう完成となりました!
テストプログラムも改良し、情報を表示するようにもしています。

f:id:sgtech:20161222105258p:plain

BC1形式にも対応する

ヘッダ解析部分の簡単な拡張で、 DirectX10以降の新しい形式である「BC1形式」に対応することも可能ですので、一緒にやってしまいましょう。
このBC1形式のピクセル格納形式はDXT1と同じであるため、ピクセルオフセットを変更するだけで先程のDXT1Decoder.CreateBitmapメソッドで展開できてしまいます。

DirectX10形式のDDSファイルは、ヘッダのサイズが20バイト増え、148バイトになっています。従ってピクセルデータの開始オフセットもそれだけ後ろにずれることになります。

DecodeDXT()メソッドにピクセルオフセットを指定できるようにしておいたことが役に立った形となりました。

f:id:sgtech:20161222105300p:plain

この内容に従って、BC1の判定部分を追加しましょう。

switch (info.Format) {
    case "DX10":
        info.PixelOffset = HeaderSize + 20;    // DX10形式ではヘッダサイズが異なり、ピクセル開始位置もずれます。
        {
            // DX10形式であることが確認できたら、さらにBC1形式かどうかを調べる。
            uint format = BinaryUtility.MakeUInt32(data, 0x80));
            switch (format) {
                case 0x46:    // BC1_TYPELESS
                case 0x47:    // BC1_UNORM
                case 0x48:    // BC1_UNORM_SRGB
                    info.Format = "BC1";
                    break;
                default:
                    return info;
            }
        }
        break;
    default:
        return info;
}

:
:

    case "DXT1":
    case "BC1":
        return DXT1Decoder.CreateBitmap(data, info.Width, info.Height, info.PixelOffset);

最後に

さて、いかがでしたでしょうか?
今回の内容で、 DXT1形式、BC1形式のDDSファイルを System.Drawing.Bitmap に変換することができました。

この応用で、DXT3,DXT5,BC2,BC3,BC4,BC5といった圧縮形式への対応も、比較的簡単にできるのではと思います。
今後また機会がありましたら、次はぐっと複雑な、BC7形式を展開するコードの説明ができればと思っております。

なお、今回の記事を執筆するにあたり、多くのサイトを参考にさせていただきました。この場を借りてお礼を申し上げます。

それでは次回の更新をお楽しみに!

Powered by はてなブログ