読者です 読者をやめる 読者になる 読者になる

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

SEGA DDS C# 画像処理
  • みなさん初めまして。セガゲームス開発技術部の内田です。

普段は、ゲーム開発に利用するツールやライブラリなどを作成する仕事をしています。
今回は、そのツール開発で必要になった機能を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形式を展開するコードの説明ができればと思っております。

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

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

Unityでアニメーションのキーフレームリダクション

Animation HND Unity SEGA

セガゲームス 開発技術部 プログラマーの樽美です。

普段は弊社独自の3D中間データ構造である「HND」の仕様策定やSDK作成、それに関連するツールの作成、
データパイプラインの整備、またSoftimageやMayaなどDCCツールのプラグインなどを作成したりしています。
最近ではUnityを触ることが多いです。

今回このブログを担当させていただくことになりました。
よろしくお願いいたします。


早速ですが、どんなネタを記事にすればよいのかいろいろ考えた末、割と役に立ちそうである
Unityでのアニメーションのキーフレームリダクションを行った時のことについて書いていきたいと思います。

動機

UnityでFBXファイルをインポートすると、DCCツールでFBXをBakeせずに出力しても、
インポート時にBakeされたアニメーションになるようです。

そして、UnityのFBXインポートオプションのAnimationsで「Keyframe Reduction」や
「Keyframe Reduction and Compression」を選択しても、
元のデータより打ってあるキーが増えてしまうことがあります。


Maya上でのキー
f:id:sgtech:20161118213448p:plain

Unityで「Keyframe Reduction」を指定してインポートした時のキー
f:id:sgtech:20161118213449p:plainf:id:sgtech:20161118213450p:plain
画像では少し見難いですが、X,Y,Zそれぞれで13個のキーに増えてしまっています。
※Xのカーブが上下反転しているのはMayaとUnityで座標系が異なるためです。

この増えてしまったキーをUnity上で削除しても特に問題はないようで、
Unity的にポジションやスケールなどは、X,Y,Zの3軸が揃っていなければならない訳ではなさそうです。


ローテーションについてはUnity内部でクオータニオンに変換されているようなので、
キーが3軸が揃ってしまうのは致し方ないかと思いますが、ポジションやスケールなど、
さらにキーを削除できる余地があるとなれば、ランタイムで使用するメモリ量の削減や
アセットファイルのデータ量削減などが期待できるので、出来れば不要なキーを削除したいですよね。


とはいえ、不要なキーを削るにしてもどのようにすればよいのでしょうか。
自分はひとまずこのように考えてみました。

はじめに

FBXをUnityで読み込むと、アニメーションは読み取り専用になっていますので、
これをduplicate(Ctrl+D)し、編集可能なAnimationにします。
f:id:sgtech:20161118213451p:plainf:id:sgtech:20161118213452p:plain
このduplicateされたAnimationファイルに対しキーフレームリダクションを行います。

キーフレームリダクションを行ってみる

その方法ですがまず、最初と最後のキーを補間した値とその間にあるキーの値を比較し、
その差が一番大きい位置のキーを残します。

その次に最初と残したキーを補間した値と、その間にあるキーの値を比較というように
これを順次繰り返し、必要なキーを残していくようにしました。
f:id:sgtech:20161118213515p:plain
f:id:sgtech:20161118213516p:plain
もう一つの例を見てみます。
f:id:sgtech:20161118213518p:plain
f:id:sgtech:20161118213519p:plain
f:id:sgtech:20161118213520p:plain
上記の例を見てみると必要なキーと不要となるキーの判別が上手くいっているように思えるのですが、
これだけでは確実に不要なキーを消すことや、必要なキーを残すことができませんでした。


以下の場合を見てみます。

f:id:sgtech:20161118213548p:plain
これまで通り、最初と最後のキーから補間した値と、その間にあるキーの値を比較し、
一番値の差が大きいキーをピックアップします。


f:id:sgtech:20161118213550p:plain
そこからまた次に残すキーをピックアップします。


f:id:sgtech:20161118213551p:plain
ここでもともと打たれていなかったキーで、本来ならば削除したいキーなのですが、
一番値が離れているのでピックアップされてしまいます。


f:id:sgtech:20161118213552p:plain
さらに次にも削除したいキーがピックアップされてしまいます。
このままピックアップされたキーを適用してしまうと理想とするMayaと同じキーの結果を得ることができません。


これらの問題を解決するために、以下のロジックを追加しました。

f:id:sgtech:20161118213553p:plain
ピックアップされてきたキーの値と前後のキーから補間した値を比較し、その差が大きい場合はキーを必ず残します。
またその際にはピックアップされてきたキーの場所のみ調べるのではなく、前後のキーの間も調べます。
(※ハンドルの傾きによってたまたま値が一致している場合があるため。)

そうでない場合は、残すかもしれないキーとして一時保留しておきます。


これで大体は判別が出来るようになりましたが、これだけではまだピックアップされたキーが
(しきい値にもよりますが)補間された値に近い場合に、不要なキーとして判別されてしまうことがありました。
f:id:sgtech:20161118213628p:plain

そのため最初に判定を行った後に、補間した値とすべてのキーが同じであった場合、
そのキーと次のキー間の値を比較するようにしました。
f:id:sgtech:20161118213630p:plain

そして一通りのキーが調査出来たら、残すことが確定しているキーと一時保留しているキーのみで
順次チェックを行い、一時保留しているキーが本当に必要であるかを判別します。
f:id:sgtech:20161118213632p:plain
f:id:sgtech:20161118213633p:plain
f:id:sgtech:20161118213635p:plain
f:id:sgtech:20161118213700p:plain
f:id:sgtech:20161118213701p:plain
だいぶいい感じにキーフレームのリダクションが行えました。

しかしここにきてこれまでの事を振り返ってみて、ふと思いました。
実はピックアップすることなど最初から必要なく、
順次キーが必要かどうかを判定していくだけでよかったのではないかと。


早速、試してみました。
f:id:sgtech:20161118213703g:plain
なんということでしょう!
思ったとおりでした(汗)

この方法では最初の方法よりも記述するコードがはるかに少なく、
しかもフルキーのデータに対しても良い結果が得られました。


最終結果です
f:id:sgtech:20161118213712p:plainf:id:sgtech:20161118213713p:plain
今回の例では、Maya上で作成したアニメーションのキーの数と全く同じにすることが出来ました。

コード一式

今回作成したコードはAnimationClipファイルの右クリックメニューから呼び出す方法で実装してみました。
そしてAnimationClipファイル内のすべてのAnimationCurveに対してキーリダクションを行っています。
また、同じ値とするかどうかのしきい値は0.0001fで固定にしています。

※以下のコードはあくまでサンプルコードです。Unity Version 5.4.0f3での動作を確認。

using System.IO;
using System.Linq;
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;

public class KeyReductionProcessor : AssetPostprocessor
{
    [MenuItem ("Assets/Key Reduction")]
    static void KeyReduction()
    {
        Debug.Log ("Key Reduction...");
        foreach (UnityEngine.Object obj in Selection.GetFiltered(typeof(AnimationClip), SelectionMode.Editable))
        {
            string path = AssetDatabase.GetAssetPath(obj);
            // AssetPathよりAnimationClipの読み込み
            AnimationClip anim_clip = (AnimationClip)AssetDatabase.LoadAssetAtPath(path, typeof(AnimationClip));

            foreach (var binding in AnimationUtility.GetCurveBindings(anim_clip).ToArray())
            {
                // AnimationClipよりAnimationCurveを取得
                AnimationCurve curve = AnimationUtility.GetEditorCurve(anim_clip, binding);
                // キーリダクションを行う
                AnimationCurveKeyReduction(curve);
                // AnimationClipにキーリダクションを行ったAnimationCurveを設定
                AnimationUtility.SetEditorCurve(anim_clip, binding, curve);
            }
            // AnimationClip名の作成
            string anim_clip_name = Path.GetDirectoryName(path) + "/" + Path.GetFileNameWithoutExtension(path);
            // AnimationClipファイルの書き出し
            WriteAnimationCurve(anim_clip, anim_clip_name);
        }
    }

    // AnimationClipファイルの書き出し
    static private void WriteAnimationCurve(AnimationClip anim_clip, string anim_clip_name)
    {
        string tmp_name = anim_clip_name + "_tmp.anim"; // テンポラリファイル名
        // AnimationClipのコピーを作成
        var copyClip = Object.Instantiate(anim_clip);
        // テンポラリAnimationClipファイルの作成
        AssetDatabase.CreateAsset(copyClip, tmp_name);
        // テンポラリファイルから移し替え
        FileUtil.ReplaceFile(tmp_name, anim_clip_name + ".anim"); // コピー先ファイルがなければ自動で生成される。
        // テンポラリAnimationClipファイルの削除
        AssetDatabase.DeleteAsset(tmp_name);
        // データベースの更新
        AssetDatabase.Refresh();
    }

    // 2つのキーから、指定した時間の値を取得する
    static private float GetValueFromTime(Keyframe key1, Keyframe key2, float time)
    {
        float t;
        float a,b,c;
        float kd,vd;

        if (key1.outTangent == Mathf.Infinity) return key1.value; // コンスタント値

        kd = key2.time - key1.time;
        vd = key2.value - key1.value;
        t = (time - key1.time) / kd;

        a = -2 * vd + kd * (key1.outTangent + key2.inTangent);
        b =  3 * vd - kd * (2 * key1.outTangent + key2.inTangent);
        c = kd * key1.outTangent;

        return key1.value + t * (t * (a * t + b) + c);
    }

    // 指定したキーの値はKey1とKey2から得られた補間値と同じ値であるかを調べる
    static private bool IsInterpolationValue(Keyframe key1, Keyframe key2, Keyframe comp, float eps)
    {
        // 調査するキーのある位置
        var val1 = GetValueFromTime(key1, key2, comp.time);

        // 元の値からの差分の絶対値がしきい値以下であるか?
        if (eps < System.Math.Abs(comp.value - val1)) return false;

        // key1からcompの間
        var time = key1.time + (comp.time - key1.time) * 0.5f;
        val1 = GetValueFromTime(key1, comp, time);
        var val2 = GetValueFromTime(key1, key2, time);

        // 差分の絶対値がしきい値以下であるか?
        return (System.Math.Abs(val2 - val1) <= eps) ? true : false;
    }

    // 削除するインデックスリストの取得する。keysは3つ以上の配列
    static public IEnumerable<int> GetDeleteKeyIndex(Keyframe[] keys, float eps)
    {
        for (int s_idx = 0, i = 1; i < keys.Length-1; i++) {
            // 前後のキーから補間した値と、カレントのキーの値を比較
            if (IsInterpolationValue(keys[s_idx], keys[i+1], keys[i], eps)) {
                yield return i; // 削除するインデックスを追加
            }
            else {
                s_idx = i; // 次の先頭インデックスに設定
            }
        }
    }

    // 入力されたAnimationCurveのキーリダクションを行う
    static public void AnimationCurveKeyReduction(AnimationCurve in_curve, float eps = 0.0001f)
    {
        if (in_curve.keys.Length <= 2) return; // Reductionの必要なし

        // 削除インデックスリストの取得
        var del_indexes = GetDeleteKeyIndex(in_curve.keys, eps).ToArray();

        // 不要なキーを削除する
        foreach (var del_idx in del_indexes.Reverse()) in_curve.RemoveKey(del_idx);
    }
}

ファイルへの書き出し

次にこのキーフレームリダクションした結果をファイルに出力しなければなりませんが、
何も考えずに既存のAnimationClipファイルに対し上書きすると、
それまで利用していたGameObjectとのリンクが途絶えてしまいます。
f:id:sgtech:20161118213725p:plain

これをを防ぐためには、一度テンポラリのAnimationClipファイルを作成し、
既存のAnimationClipファイルに対してReplaceFile()を行えば、リンクが途絶えることがないようです。

真面目に行うならユニークなファイル名を生成するところですが、
今回はベースとなる名前の後ろに”_tmp”を付随しているだけです。

余談ですがReplaceFile()でファイルを更新すれば、AnimationClipファイルだけではなく
他のアセットファイルやprefabファイルの更新の時にMissingになってしまうことも防げるようです。

しかしここでまた衝撃的な事実が。
キーフレームリダクションを行い、キーの数は確実に減っているのですが、そのデータをファイルに書き出すと
元のAnimationClipファイルよりファイルサイズが大きくなってしまうことがありました。

特に「Keyframe Reduction and Compression」を指定してインポートしたデータの場合と比べると
キーの数が少なくなっているはずなのにファイルサイズがかなり大きくなってしまいました。

何が原因なのかとリダクションする前のAnimationClipファイルを開いてみると
m_EditorCurves:の項目が出力されない事があるためにファイルサイズが小さく済んでいるようです。
(※出力される場合もあり、この辺りのUnityの挙動がよく分かっていません。)

UnityのAPIのAssetDatabase.CreateAsset()でファイルを作成したときにもこのファイルフォーマットで
出力できればよいのですが、どうも現状ではその方法はなさそうです。


と、その前にX,Y,Zそれぞれをキーリダクションしてもファイルに出力すると必ず3軸揃った
データも出力されていることがわかりました。

  m_PositionCurves:
  - curve:
      serializedVersion: 2
      m_Curve:
      - time: 0
        value: {x: -0, y: 0, z: 0}
        inSlope: {x: -0.00000008580087, y: 0.061224494, z: 0.0000000031700533}
        outSlope: {x: -0.00000008580087, y: 0.061224494, z: 0.0000000031700533}
        tangentMode: 0

※出力したAnimationClipファイルより一部抜粋

X,Y,Zすべて削除している場合には、確かにその時間のキーは無くなってはいるのですが、
ひとつでもキーが残っていると結局3軸分出力されるようで、なんかちょっとショックです。

まとめ

始めに考えたコードはまったく無用のものになってしまい、だいぶ遠回りをしてしまいましたが、
キーリダクションそのものについては大体良い結果が得られるようになったと思います。
しかし、ファイルサイズ削減についてはインポートした結果よりデータが増えてしまう事があるという残念な結果に。
それでも、あれこれ試行錯誤している時はとても楽しかったです。
カーブによってはこのロジックだけでは対応しきれないかもしれませんが、
この記事がこれからアニメーションのキーフレームリダクションを行いたいと思われる方の参考になれば幸いです。


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

モダンな OpenGL で頂点モーフ

OpenGL Shader SEGA

セガゲームス、開発技術部の山田@プログラマです。
普段はゲームのランタイムライブラリ作成やらツール作成やら、深追いデバッグを担当しています。
今回より本ブログにデビューとなりましたのでよろしくお願いします。

さてさて今回のお題に入りましょう。
最近は DirectX12 や Vulkan といった新しい API がありますが、今回は OpenGL の比較的新しい機能の紹介をしたいと思います。

お題: Shader Storage Buffer Object を使う

OpenGL 4.3 で入った Shader Storage Buffer Object (SSBO) というものを使ったことはありますか?

この SSBO は知名度が低いのですが、シェーダーから自由に読み書きができるという点ですごく便利です。
シェーダーにバッファをセットするという点では Uniform Buffer Object (UBO, 定数バッファ) がありますが、
これは読み込みしかできません。書込みが可能な SSBO を使って何ができるでしょうか。

1つの例として今回は SSBO を用いての頂点モーフを考えてみましょう。
近年の描画処理においてはモデルデータを1回描画して終わりではありません。
複数回同じモデルを色々なシェーダーで描画して、最終的な画面結果を作り上げていきます。
そのため、頂点モーフの処理もまた複数回必要になってきます。
しかしながら形状自体は同じであるため、頂点モーフの結果を使いまわせれば処理コストを節約することが出来そうです。
以降はその実装についてのご紹介です。

SSBO のチュートリアル

SSBO は以下のようにして生成します。 OpenGL におけるバッファオブジェクトの生成方法と同じです。

GLuint ssbo;
glGenBuffers(1, &ssbo);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo );
glBufferData(GL_SHADER_STORAGE_BUFFER, bufferSize, buffer, GL_STATIC_DRAW );

使う際には、この作成した SSBO のオブジェクトを glBindBufferBase で任意のバインディングポイントにセットしていきます。
バインディングポイントの最大数は GPU のドライバに依存するようなので若干注意が必要です。

glBindBufferBase( GL_SHADER_STORAGE_BUFFER, bindingPoint, ssbo );

こうやってセットしたバッファを シェーダーから使うには以下のように記述します。
不定の長さのものを扱えるところも SSBO の扱いやすいポイントです。

#version 430
layout(location=0) in vec4 position;

layout(std430,binding=0) readonly buffer SSBO {
  vec4 data[];
} gSSBO;

void main() {
   vec4 data0 = gSSBO.data[0];
   vec4 data1 = gSSBO.data[2];
}

頂点モーフの実装

SSBO の使い方を分かったところで、頂点モーフの実装を考えてみます。
複数のモーフターゲットがあることが普通で、処理できる個数を限定してしまうことはしたくありません。
そこで各パラメータによって複数のモーフターゲットの成分を合成し、
描画する際にはこの成分と加算することで形状が確定するということにしてしまいます。
この成分の合成する先として SSBO を使います。

f:id:sgtech:20161027134536p:plain:w300

描画のための OpenGL の呼び出しコードは以下のようなものとなります。
各頂点分だけのモーフ成分を計算したいので、 GL_POINTS で頂点分を処理しているところがポイントでしょうか。
ピクセルとしての書き込みを行う必要もないので GL_RASTERIZER_DISCARD を有効化していたりするのもポイントですね。

glUseProgram( ssboMorphShader );
glBindVertexArray( ssboMorphVAO );
glEnable(GL_RASTERIZER_DISCARD);
glDrawArrays(GL_POINTS, 0, vertexCount );

glDisable(GL_RASTERIZER_DISCARD);
glUseProgram( drawMorphShader )
glBindVertexArray( drawVAO );
glDrawElements( GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, nullptr );

実行してみると以下のようになりました。

素直に実装したマルチストリームの頂点シェーダー版と比べて、
数回描画する場合には SSBO を使って処理したほうが高速に処理できることが確認できます。

f:id:sgtech:20161027134340g:plain

One more thing

さて、頂点モーフターゲットの成分を合成している部分について注目してみると、
テクスチャ未使用、ピクセルシェーダーも不要と、単なる計算処理しかしていません。
この部分について OpenGL にも搭載されている ComputeShader (以降 CS) を使ってみることにしましょう。

モーフターゲットについては SSBO で作成しているので、そのまま CS にセットできます。
また、今回も SSBO に結果を書き込むため、描画処理そのものは先ほどのものと共通で使えます。

計算処理から描画までの OpenGL の呼び出しコードは以下のようなものになります。
モーフの計算部分については Vertex Array Object (以降 VAO) のセットが不要です。

glUseProgram( computeMorphShader );
glDispatchCompute( vertexCount/32+1, 1,1);

glUseProgram( drawMorphShader )
glBindVertexArray( drawVAO );
glDrawElements( GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, nullptr );

同じ SSBO を使ったコードで、頂点シェーダー版と ComputeShader 版とで、
実行について速度差が生まれるかどうかについてですが、今回の例においては劇的な差はありませんでした。
若干 頂点シェーダーSSBO出力版のほうが軽いでしょうか。

f:id:sgtech:20161027134503g:plain:w400

最後に

SSBO を用いた頂点モーフの実装例を紹介しました。
勢い余って OpenGL の Compute Shader による実装までやってしまいましたが、いかがでしたでしょうか。
OpenGL 4.x もまだまだ面白いと感じてもらえたら幸いです。




モデル協力:セガ・ハード・ガールズ


セガ・ハード・ガールズ公式 HP:http://shg.sega.jp/ 公式twitter:アソビン教授(セガ・ハード・ガールズ) @SHG_Official TVアニメ『Hi☆sCoool! セハガール』公式HP: http://shg.sega.jp/anime.html

(C)SEGA /セハガガ学園理事会


CEDEC2016プロダクションラウンドテーブルの振り返りとレガシーなJenkins環境に纏わる話

QAエンジニア ビルドエンジニア Jenkins SEGA

f:id:sgtech:20160926170951j:plain

 

こんにちは。セガゲームス開発技術部所属のこかわです。

社内ではビルドエンジニア&QAエンジニア(自称)として、開発部署やQA部署の業務がスムーズに進むためのサポートを行っています。CEDEC運営委員等、社外でも開発者向けイベントを中心に活発に活動しています。

今回はまだ開発者ブログならではの内容は薄めですが、CEDEC2016を振り返りながら、そこから繋げてセガ社内のプロダクション技術の話をしてみたいと思います。

 

キーワードを解説しながら要約すると、「ビルドエンジニアリング=チームで(巨大な)アプリケーションを開発する中で最新の動くアプリケーションを安定かつ素早く作る(ビルド)仕組み」が「レガシー=長い期間使い続ける事で古くなった、先任者が作り上げて手が出せなくなった」状況に対してどう取り組んでいくかというお話です。

 

CEDEC2016プロダクションラウンドテーブルの話

f:id:sgtech:20160926170952j:plain

プロダクションラウンドテーブル | 公式サイト | CEDEC 2016 | Computer Entertainment Developers Conference

CEDEC2016では、上記ラウンドテーブルの中で「レガシー開発環境をなんとかする」というテーマの担当をしました。(ラウンドテーブル記録資料:CEDiL

CEDECでは最近プロダクション分野ができ、ゲーム開発会社内でもビルドエンジニアとしてビルド周り自動化の専任者も出てきている中で、環境のメンテナンス・自動化技術の属人化といった課題が取り上げられました。

特に自動化で業界デファクトスタンダードとして使われているJenkinsの話題は例年多く話題に挙がります。(Jenkins: https://jenkins.io/

こかわは長い間このJenkinsに関連するプロダクション環境のサポートを社内で行ってきましたので、そこにフォーカスして話を進めます。

 

自身がセガ社内で扱ってきたJenkins環境

これまでに作ったJenkins環境

2008年入社から今までで、ぱっと思い出せる範囲で8インスタンス以上は本番運用されるJenkinsを立てました。1年に1Jenkinsは立てている事になりますね。

最初は、アーケードゲーム向けライブラリの自動ビルドから始まり、

 CEDEC2010 タダで始めるゲーム開発自動化のススメ

Unity製スマートフォン向けアプリの自動ビルドや自動デプロイ環境の構築がたくさんあって5つくらい、

 [Unite Japan 2013]Unity × Jenkins:一歩進んだ使い方 on Vimeo

その他1つくらい、といった感じでそこそこの回数、運用を考えながらJenkins環境を立ててきました。

ゲーム開発の自動化はプラットフォームが多様な点を軸に、言語やコンパイラなど下回りが全く異なったり、扱うデータがソースコードだけでは無い点など、いろいろやれる事が多くて楽しいです!

 

こういった中で、いろいろ背景や運用方法、導入時期の異なるJenkins環境を立ててきた結果、完全にレガシー状態になっているもの、レガシーにならないように気をつけて運用しているものそれぞれ存在するので、今回はそこから極端な事例をピックアップして具体的に紹介してみようかなと思います。

# ここでのレガシーは、Jenkins環境(本体バージョンや設定の属人化)に限定して話を進めます。

 

1. 完全にレガシー状態になっているJenkins環境

CEDEC2010 タダで始めるゲーム開発自動化のススメ この講演内の実例の環境です…。

2008年に入社後ライブラリチームに入った直後に立ててから、2016年現在まで稼働し続けています。

ライブラリ自体がレガシーになり頻繁な開発には使われなくなったものの、バグ修正依頼に対処した後の、リリース作業の自動化という点でまだ現役です。

Jenkinsバージョン:1.487

一回アップデートしようとした時に起動せず⇒元のバージョンに戻して復旧

というトラブル以降、バージョンを上げていません。

特定のプラグインでエラーが出ていたところまで特定しましたが、対応する時間を取るのを諦めてそのままになっています。

ジョブ数:163

上のCEDEC講演資料で紹介したような内容(自動ビルド、自動テスト、静的解析3種類以上、コードメトリクス計測、ドキュメント生成、パッケージリリースなど)に加えて、当時は個人のミーティングの予定などもRedmineでチケットにしていたのでそのリマインダーメールがJenkins経由で飛んでくるジョブなど、若気の至りで作ったレガシーなジョブの集合体になっています。いろいろな事を1つのJenkinsに詰め込んでプラグインも多く入っているのでこれがバージョンアップを阻害している要因でもあります。自作のJenkinsプラグインも入っています。

結論

この環境はレガシーから脱却するのを諦め、このまま最低限必要な機能を維持したまま、役割が終わるのと同時に閉じるつもりでいます。

ラウンドテーブルでも触れましたが、プロジェクトの終わりが見えている場合や今後ジョブの追加や変更など変更が予想されない場合はそのままの環境で走りきるのも手だと思います。

# もちろん、セキュリティ面やハードウェア交換、関わる人などの状況によって、きちんと対応しないといけないラインは変わると思います。

Jenkinsの設定については他のプロジェクトや今後も使えるジョブはそこだけ取り出して、新しいJenkins環境に移す事を考えた方がスムーズでした。

 

2. レガシーにならないように気をつけて運用しているJenkins環境

CEDEC2015 長期運営タイトルに後からパイプラインの自動化を導入した際の技術的Tips この事例でのJenkins環境を紹介したいと思います。

ここでは、自動化設定が複雑かつ長期的にメンテする可能性があったので、レガシーにならないように気をつけて運用した例です。

 

gitでJenkins設定の管理

ラウンドテーブルで提案した通り、Jenkinsの設定ファイルをgitでバージョン管理しています。

このやり方の最大のメリットは、設定のバックアップという意味以上に、コミット時に設定変更の意図を残せる点です。

 

コミットログはこんな感じです。

コミット日時作成者コメント
b6d6682e 2015/01/13 14:56 粉川 貴至 リファクタリング。古いJobの整理。名前修正。
c4ac6f8b 2015/01/14 16:21 粉川 貴至 ビルド失敗時にビルドログをメール本文に追加
7e2c1957 2015/01/14 16:47 粉川 貴至 結果通知メール微修正
9a1eb98a 2015/01/14 21:31 粉川 貴至 遅延展開書き忘れ
7cdde7a4 2015/10/27 11:22 粉川 貴至 #2740 コンバート時のJenkins設定ミスを修正
47e02bbd 2016/02/22 11:51 粉川 貴至 #3630 タイトル機能追加に伴う自動処理追加対応

 

Jenkins設定を作りながらJenkins運用も進めている場合に「どのタイミングで設定をどう変えたか」をコミットログですぐに確認する事ができます。

また、運用開始後に問題や変更が起こった場合、チームのイシュートラッカーにチケットを立てから対応して、コメントにチケットIDを含めています。チームの開発の流れと同列に、振り返る必要があった時に見つけられるようにこのようにしています。

 

後はこれをやる時に、実際に動いているJenkins環境の JENKINS_HOME 以下を直接バージョン管理対象にしているのですが、ビルド時に作られるファイルなど設定だけを管理する上で必要の無いファイルは除外しています。

同じようにする場合の .gitignore ファイルのサンプルを置いておきますね。

.owner
jobs/*/builds/*
jobs/*/workspace/* 
jobs/*/lastStable jobs/*/lastSuccessful jobs/*/nextBuildNumber
logs/ war/ updates/ secrets/ fingerprints/ /*.log
Redmineと連携して設定のレビュー

ラウンドテーブルで挙がった「Jenkins設定が属人化されて1人しか触れない」問題に対しては、Redmine Code Review プラグイン( http://www.r-labs.org/projects/r-labs/wiki/Code_Review )を利用して、情報共有を行っています。

レビューチケット例 

f:id:sgtech:20160926170950p:plain

この場合の設定のレビューは、xmlファイルを比較したり確認したりする事になるので視認性は悪いですが、ある程度Jenkinsの管理に慣れてさえいれば、設定を差分で確認できる事の方がメリットが大きいです。

また、先の通りJenkins設定がバージョン管理されているので、レビューを回された当人の手元で該当バージョンの環境を再現する事もできます。

結果

このJenkins設定のバージョン管理とレビューの仕組みは、うちのチームに社内Jenkins環境サポート人員が1人増えて2人になったタイミングで導入しました。

該当プロジェクトのJenkins設定を把握してもらうのに、非常に効果的に働きました。

1人でJenkins設定の面倒を見ている場合でも、年単位で運用していると設定変更の経緯を忘れている事も多いので、本当にこれはやっておく価値があると思います。

最新の Jenkins 2 ではまた違ったアプローチも考えられますが、今日はここまで。

 

まとめ

という事で、CEDEC2016のフォローアップ記事+ちょっと突っ込んだ内容という形で、社内で実際に運用しているJenkins環境を2つ紹介しながら色々書いてみました!

 

ゲーム開発を支える技術ということでゲーム開発の技術そのものでは無いためちょっと興味から遠い方も多かったかもしれませんが、毎回執筆者も内容も変わる予定ですので次回をお楽しみにお待ちください!

技術ブログ始めます&CEDEC2016セガグループセッション紹介

CEDEC カンファレンス SEGA

セガゲームス、開発技術部の麓です。

本日からセガグループの技術ブログを開始します。

ここではセガグループの、面白そうな、役に立ちそうな、興味を持って貰えそうな。

そんな技術に関係する記事を月一程度の頻度で連載していきます。

 

先ずは第一弾、近々開催されるカンファレンス。

知る人ぞ知るCEDEC2016(2016年8月24日~26日)で、登壇(関係)者数15名に及ぶセガグループの社員が講演に関係するセッションの紹介をします!

※順不同

 

スマホゲームにおけるゲーム性と物語性の“運用で摩耗しない”基礎設計手法 ~チェインクロニクル3年の運用と開発の事例を交えて~ 【レギュラーセッション】
スマートフォンの国産ネイティブゲームにおける主流のゲームサイクル(キャラクターコレクションを中心に置き、イベント投下型の運営が行われるタイトル) において、あらかじめ用意されていたゲーム性と物語性は、運営における拡張の過程で、徐々にその価値を摩耗していくことが宿命づけられています。
本セッションでは、チェインクロニクルでも用いた「ゲーム性と物語性の摩耗を初期設計によっていかにして防ぐか」という手法について解説し、チェインクロニクルの初期開発および3年におよぶ運用における事例を交えつつ、汎用的な基礎設計指針を提示していきます。
株式会社セガ・インタラクティブ
松永 純
日時 : 8月24日(水) 11:20~12:20

 

技術から語る「龍が如く」の10年 ~ 今世代で何が変わるのか ~ 【レギュラーセッション】
10年の節目を迎えた「龍が如く」シリーズ グラフィックスと物理制御を中心に技術面での変遷を振り返り、どのような進化を遂げてきたのかを踏まえた上で、PS4専用タイトルとなる、最新作「龍が如く6(仮)」においてどのような新しい試みが行われているのかを紹介します。
株式会社セガゲームス
厚 孝 ・ 藁間 直弘 ・ 高木 英嗣
日時 : 8月24日(水) 11:20~12:20

 

龍が如く6における次世代神室町の作り方
龍が如く6における次世代神室町の作り方について解説するセッションです。
シリーズを通してメインステージとして登場し続けた神室町を次世代に向けてリニューアルするにあたって立てた構想と、実際に行った検証例、昨今のトレンドとなっている物理ベースレンダリングの導入、過去作から引き継いでいる経験と知恵などについて説明するセッションになります。
内製エンジンでの制作を行っているので内製エンジンで制作するメリット、デメリットなどについても触れられればと思います。
株式会社セガゲームス

濱津英二 ・ 中井友泰

日時 : 8月24日(水) 17:50~18:50

 

大量外部発注時代を乗り越える為の発注方法 【ラウンドテーブル】
ハードの性能があがり、求められる物量が大量になっていく中、内部制作だけではまかなえなくなり、外部発注の需要が高まっています。 外部発注の経験が深い各会社が「外部発注のノウハウ(独自の秘訣)」、「クオリティコントロールをどのように行っているか」「今後のベストな形」について具体例を交えながら紹介いたします。
株式会社セガゲームス ほか
木津 恵一 ほか
日時 : 8月24日(水) 14:50~15:50

  

プロダクションラウンドテーブル 【ラウンドテーブル】
プロダクション分野に関する、手法・技法にまつわる課題や解決事例などを共有するためのラウンドテーブルです。
CEDEC2016プロダクション分野定義: http://cedec.cesa.or.jp/2016/koubo/field.html#g02 
株式会社セガゲームス ほか 
粉川 貴至 ・ 竹原 涼 ほか
日時 : 8月25日(木) 11:20~12:20

 

Technical Artist Bootcamp 2016 vol.1 【レギュラーセッション】
前半:「プロジェクト配属型TAとしての働き方、組織活動の実例とツール開発のヒント」 弊社のアーティスト出身者で構成されるTAセクションの活動を例にプロジェクト配属型TAのプロジェクト内での活動とTA組織としてアーティスト全体へのフィードバック事例と組織で活動することの意味。
そしてMAYAのツール開発を例にアーティスト向けツールの開発、導入、普及、運用の取り組み方。 
株式会社セガゲームス セガネットワークス カンパニー ほか 
亀川 祐作 ・ 麓 一博(セッションコーディネートのみ) ほか
日時 : 8月24日(水) 11:20~12:20

 

MAKING OF "THE GIFT" ~ Unityを用いた高品質映像制作 ~
GDCで公開され話題になったMARZA製オリジナルショートムービー "THE GIFT"。このムービーは全てunityでシーン構築&画像出力されています。
本セッションでは、映像プロダクションである我々がunityエンジンを用いて、どのように映像制作に行ったのかをプロダクションの視点から説明します。
具体的には、プロジェクトの目的、ワークフロー、パイプライン、アセット作成、シェーダー開発、エフェクト開発の項目ごとに詳細を説明し 最後にまとめとして、本作品制作においてunityがどのようにパフォーマンスを発揮したか、逆にどの点で苦労したか 今後はどのような開発を進めていくつもりなのかをお伝えします。
マーザ・アニメーションプラネット株式会社 

加治佐 興平 ・ 守随 辰也 ・ 鴻巣 智 ・ 松村 知哉 ・ 松成 隆正 ほか

日時 : 8月24日(水) 13:30~14:30

 

CEDECに参加される皆様はもちろんのこと、CEDECを知らない方もこれを機に興味を持って技術共有と交流の場を大いに活用して頂けることを願っています。

それでは今月はここまで。

次回の更新をお楽しみに。

 

※複数社登壇の場合でもセガの社員のみ表記しています