WebOS Goodies

WebOS の未来を模索する、ゲームプログラマあがりの Web 開発者のブログ。

WebOS Goodies へようこそ! WebOS はインターネットの未来形。あらゆる Web サイトが繋がり、共有し、協力して創り上げる、ひとつの巨大な情報システムです。そこでは、あらゆる情報がネットワーク上に蓄積され、我々はいつでも、どこからでも、多彩なデバイスを使ってそれらにアクセスできます。 WebOS Goodies は、さまざまな情報提供やツール開発を通して、そんな世界の実現に少しでも貢献するべく活動していきます。
Subscribe       

iPhone でテクスチャ圧縮 (PVRTC) を使う

iPhone で 3D CG! OpenGL ES を使ってみよう」では、 iPhone で基本的な 3D モデルを表示する方法をご紹介しました。 iPhone の GPU は意外と強力で、クオリティーの高い映像を生成するための拡張機能が他にもいろいろと搭載されています。今回はそのひとつである「テクスチャ圧縮」の使い方をご紹介しようと思います。

デスクトップ PC における 3D CG でもテクスチャ圧縮は重要な技術ですが、リソースの限られた iPhone ではそれ以上にアプリケーション全体のできを左右するものになります。幸い、 iPhone のテクスチャ圧縮機能はデスクトップ向けの GPU 以上に強力なので、うまく利用して優れたアプリケーションの開発に役立てたいものですね!

テクスチャ圧縮ってなに?

実際にテクスチャ圧縮を使う前に、テクスチャ圧縮について少し説明しておこうと思います。基本的には PNG, JPEG などと同様にテクスチャ画像を圧縮しよう、ということなのですが、そこにはリアルタイム 3D CG ならではの工夫があります。と言っても、 3D CG に詳しい方にとっては当たり前のことなので、そのような方は「texturetool で圧縮テクスチャを作成する」まで読み飛ばしてください。

なぜテクスチャ圧縮を使うのか

データを圧縮する目的は、一般的にはデータ量を削減することです。テクスチャ圧縮もその例にもれず、 3D モデルで使われる大量のテクスチャを限られたビデオメモリに効率よく格納するのが第一の目的です。そうすれば同じメモリ容量でも多くのテクスチャが使用でき、よりリアルな 3D シーンが生成できます。

しかし、リアルタイム 3D CG においては、もうひとつ重要な利点があります。それはGPU⇔ビデオメモリ間の帯域幅の節約です。最近は GPU が非常に高速になり、複数枚のテクスチャを利用した高度な描画処理が可能になっています。ということは、それだけ多くのテクスチャをビデオメモリから GPU にリアルタイムで転送する必要があり、あっという間に帯域幅を使い切ってしまうのです。そこで、テクスチャを圧縮した状態でビデオメモリに格納しておき、それを転送後に GPU 内部で展開することで、帯域幅を節約しているわけです。ちょうど、 Web サーバー側にある .tgz 形式の圧縮データをブラウザ側で展開することでネットワーク帯域を節約するのと同じ理屈ですね。これにより、メモリ帯域がボトルネックになるのを避け、より高速な描画を実現しています。

このように、テクスチャ圧縮にはメモリ容量と帯域幅の双方を有効活用するために行われます。とくに iPhone はそのいずれもがデスクトップ PC よりも制限されているので、テクスチャ圧縮は非常に重要と言えるでしょう。

PNG や JPEG じゃだめなの?

それなら、 PNG や JPEG で圧縮した画像を使えばいいじゃない!と思うかもしれません。しかし、残念ながらそれは(少なくとも現時点では)不可能です。それらの画像形式は、読み込み時に画像全体を展開して、メモリ上には非圧縮の形式で保持することを想定して作られています。それではメモリ消費と帯域幅のいずれも節約できません。

メインメモリ上でも圧縮された状態を維持するためには、ハードウェア (GPU) で高速に展開でき、しかも画像の一部分を取り出して展開できる(ランダムアクセスできる)という特殊なアルゴリズムが必要です。 3D CG では多数のポリゴンにわたって一枚のテクスチャを貼付けることが多いので、ポリゴンを一枚描画するごとにテクスチャ全体を展開していたら、たいへんな無駄が生じてしまいます。必要な部分のみを高速に展開できる圧縮方式が要求されるわけです。

PVRTC 圧縮とは

前述のような画像圧縮アルゴリズムとして、 iPhone で使われている GPU (PowerVR MBX/SGX) は PVRTC という独自の方式をサポートしています。ウェーブレット変換を利用したアルゴリズムで、自然画像の圧縮に適しています。以下に PVRTC と DXT1 (DirectX 標準のテクスチャ圧縮方式)で圧縮した画像を並べてみましたので、その実力のほどをご覧ください(クリックすると拡大します)。

左上が元画像、右上が DXT1 (圧縮率 1/8 で、 DXTC の中ではもっとも高い)、左下、右下がそれぞれ PVRTC で 1/16 (2bit), 1/8 (4bit) に圧縮したものです。 DXT1 は明らかに細部が潰れてディテールが失われているのに対して、 PVRTC (4bit) は元画像とほとんど見分けがつきません。また、 PVRTC (2bit) はさすがに画質が落ちますが、データ量がさらに半減するのは大きな利点です。

iPhone のテクスチャ圧縮はデスクトップの GPU にも引けを取らない優れたものであることがおわかりいただけるかと思います。

texturetool で圧縮テクスチャを作成する

それでは、 PVRTC で圧縮したテクスチャを作ってみましょう。 PVRTC は PowerVR 独自の圧縮形式なので、 Photoshop などのツールで直接生成することはできません。ではどうするのかというと、 iPhone SDK に含まれている texturetool というコマンドラインツールを使用して PNG などを変換することで生成します。

texturetool [オプション] -e PVRTC -o 出力ファイル 入力ファイル

texturetool で使えるオプションは以下のとおりです。

オプション機能
-hヘルプを表示
-l利用可能なエンコーダ、フォーマットなどを表示
-mミップマップの生成
-e エンコーダエンコーダの指定(現在は PVRTC に固定)
-p ファイル名PNG形式プレビューファイルの出力
-o ファイル名出力ファイル名の指定
-f フォーマット出力ファイル形式の指定
--channel-weighting-linear誤差を RGB に均等に分散
--channel-weighting-perceptual緑チャンネルの精度を優先
--bits-per-pixel-2圧縮率 1/16
--bits-per-pixel-4圧縮率 1/8

texturetool 独特のオプションを中心に、以下でその機能を詳しくご説明します。

圧縮率の選択

PVRTC 形式では、圧縮率は 1/8 か 1/16 に固定されており、 PNG や JPEG のように画像によって圧縮率が変化することはありません。これは主にハードウェアによる展開とランダムアクセスの効率を優先した結果でしょう。

どちらの圧縮率を使用するかはオプションで指定でき、 --bits-per-pixel-4 なら 1/8 、 --bits-per-pixel-2 なら 1/16 になります。指定がない場合はデフォルトで 1/8 が採用されます。とうぜん 1/8 の方が画質は良いので、画質とサイズのどちらを優先するかで選択してください。

圧縮誤差の処理方式の選択

圧縮率と同様に、圧縮誤差の処理方式も 2 種類から選べます。 RGB の各チャンネルに誤差を均等に分散する --channel-weighting-linear と、赤・青チャンネルを犠牲にして緑チャンネルの誤差を減らす --channel-weighting-perceptual です。指定がない場合は前者が採用されます。

一般的に人間の目は緑色に敏感と言われているので、 --channel-weighting-perceptual を指定すれば(人間にとっての)画質が改善できる可能性があります。しかし、そのぶん赤・青チャンネルの精度が下がるので、画像によっては逆に画質が損なわれることもあるでしょう。こればかりは圧縮結果を見て判断するしかないと思います。

出力ファイル形式

texturetool が出力できるファイルフォーマットは PowerVR 標準の PVR 形式と古い iPhone SDK で利用されていた RAW 形式の 2 種類です。これは -f オプションで指定でき、 -f PVR とすれば PVR 形式で、 -f RAW とすれば RAW 形式で出力されます。

PVR 形式は先頭に画像サイズや圧縮形式などの情報を格納した 52byte のヘッダが付加されており、読み込み時にそれらを自動判別して適切な処理を行うことができます。

それに対して RAW 形式はテクスチャのビットイメージのみをそのまま出力したもので、ファイルの内容だけでは画像サイズすら判別できません。したがって、読み込む際はあらかじめすべてのパラメータをプログラム側にハードコードしておく必要があります。

RAW 形式はとても不便なので、通常は常に PVR 形式を使うことになるでしょう。 16x16 くらいの小さなテクスチャは RAW 形式でヘッダ分をけちるのも手ですが、そういうものはいくつかまとめて大きなテクスチャにしてしまうのが一般的です。 CSS Sprite と同じ理屈ですね。

ミップマップの出力

ミップマップとは、以下のように 1/2, 1/4 などに縮小した画像をあらかじめ用意しておくことを言います(以下の画像はイメージなので、実際にこのように画像が配置されているわけではありません)。

IE7 以前や昔の Firefox などで、 IMG タグで画像を縮小して表示すると、すごくジャギジャギになってしまっていたのを覚えている方も多いと思います。 3D CG でも、テクスチャを貼ったオブジェクトが遠くに行って画像が縮小されると、同様にテクスチャの画像が極端に劣化してしまいます。これを防ぐために、あらかじめ高画質で縮小した画像を用意しておき、距離によって(正確には縮小率によって)適切なサイズのテクスチャを選択して表示するわけです。また、これによってデータアクセスの局所性も上がるので、一般的にミップマップを用意する方が描画のパフォーマンスも向上します。

というわけで、ちょっと説明が長くなりましたが、 texturetool では -m オプションを付けることでミップマップを自動生成できるようになっています。上記のような理由で、通常は常に生成するべきです。ただし、ミップマップを生成するとデータサイズが 1.3 倍程度に増えるので、 2D 表示用のアイコンなど、ほとんど縮小されないテクスチャは -m を付けない方が良い場合もあります。

元画像の制限

一般に圧縮テクスチャは処理効率を優先するため、画像サイズなどにきつい制限があります。 texturetool の仕様も含めて、元画像として指定できる画像の条件は以下のとおりです。

  • 画像は正方形(縦横のピクセル数が同じ)。
  • 縦横のピクセル数は 2 の累乗。
  • 画像サイズは最大で 1024x1024 まで。
  • 画像形式は PNG, JPEG, TIFF のいずれか。

この条件を満たさない画像は余白を入れるなどして条件に合うように加工し、表示するときに必要な部分を切り出さなければなりません。

利用例

ミップマップあり、圧縮率 8:1 の PVRTC 圧縮、誤差を均等に拡散、出力フォーマットは PVR 形式。

texturetool -m -e PVRTC --bits-per-pixel-4 -o texture.pvr -f PVR texture.png

ミップマップなし、圧縮率 16:1 の PVRTC 圧縮、緑チャンネルの精度を優先、出力フォーマットは RAW 形式。

texturetool -e PVRTC --bits-per-pixel-2 --channel-weighting-perceptual -o texture.raw -f RAW texture.png

テクスチャ圧縮を使う

PVRTC テクスチャの作成方法がわかったところで、実際にそれを iPhone で表示してみましょう。ここでは、「iPhone で 3D CG! OpenGL ES を使ってみよう」のサンプルのテクスチャを、 PVRTC 圧縮したものに差し替えようと思います。

ここで作成するサンプルのソースファイルはこちら で参照できます。まとめてダウンロードするには、 Subversion で以下のようにしてください。

svn co http://webos-goodies.googlecode.com/svn/trunk/blog/articles/using_compressed_textures_in_iphone/

圧縮テクスチャをリソースに追加

まずは、 earth.jpg を PVR 形式に変換して、 earth.pvr という名前で保存します。プロジェクトディレクトリが ~/GLSample1 であれば、以下のようにします。

cd ~/GLSample1
texturetool -e PVRTC --bits-per-pixel-4 -o earth.pvr -f PVR earth.jpg

そして、 Xcode のサイドバーで Resources を選択し、コンテキストメニューの [追加]-[既存のファイル] で earth.pvr を追加すれば OK です。

圧縮テクスチャ読み込みクラスを作る

PVR 形式のテクスチャを実際のアプリケーションで読み込む方法としては、 iPhone Dev CenterPVRTextureLoader というサンプルが公開されています。この中のクラスを使えば、簡単に PVR 形式のファイルを読み込んで、 OpenGL のテクスチャを作成できます。

しかし、私はできるだけ C++ で開発したいと思っているので、そのクラスを C++ に移植してみました。移植と言っても、インターフェースなどは自分が使いやすいように変えていますので、ご了承ください。

まずはヘッダファイルです。 Classes 以下に PVRLoader.h というファイル名で以下の内容を保存してください(ソースコード単体のダウンロードはこちらから)。

#ifndef PVRLOADER_H
#define PVRLOADER_H

#include<OpenGLES/ES1/gl.h>
#include<OpenGLES/ES1/glext.h>

class PVRLoader
{
public:

    enum Constants
    {
        PVR_FLAG_TYPE_PVRTC_2 = 24,
        PVR_FLAG_TYPE_PVRTC_4 = 25,
        PVR_FLAG_TYPE_MASK    = 0xff,
        PVR_MAX_SURFACES      = 16
    };

    struct Header
    {
        uint32_t headerSize;
        uint32_t height;
        uint32_t width;
        uint32_t numMipmaps;
        uint32_t flags;
        uint32_t dataSize;
        uint32_t bpp;
        uint32_t bitmaskRed;
        uint32_t bitmaskGreen;
        uint32_t bitmaskBlue;
        uint32_t bitmaskAlpha;
        uint32_t tag;
        uint32_t numSurfaces;
    };

    struct Surface
    {
        GLuint      size;
        const void* bits;
    };

    PVRLoader();
    ~PVRLoader();
    bool LoadFromFile(const char* path);
    bool LoadFromMemory(const void* data);
    bool Submit();

    GLuint         GetWidth()               const { return width; }
    GLuint         GetHeight()              const { return height; }
    GLenum         GetFormat()              const { return format; }
    bool           HasAlpha()               const { return hasAlpha; }
    GLuint         GetSurfaceCount()        const { return numSurfaces; }
    const Surface& GetSurface(GLuint level) const { return surfaces[level]; }

private:

    GLuint  width;
    GLuint  height;
    GLenum  format;
    bool    hasAlpha;
    Surface surfaces[PVR_MAX_SURFACES];
    GLuint  numSurfaces;
    char*   readBuffer;

    static const char PVRIdentifier[4];

    void AllocReadBuffer(int size);
    void FreeReadBuffer();
};

#endif

次はインプリメンテーションの .cc ファイルです。ヘッダと同様に、 Classes 以下に PVRLoader.cc という名前で保存してください(ダウンロードはこちら)。

#include <libkern/OSByteOrder.h>
#include <fstream>
#include "PVRLoader.h"

using namespace std;

const char PVRLoader::PVRIdentifier[4] = { 'P', 'V', 'R', '!' };

PVRLoader::PVRLoader()
{
    numSurfaces = 0;
    readBuffer  = NULL;
}

PVRLoader::~PVRLoader()
{
    FreeReadBuffer();
}

void PVRLoader::AllocReadBuffer(int size)
{
    FreeReadBuffer();
    readBuffer = new char[size];
}

void PVRLoader::FreeReadBuffer()
{
    if(readBuffer)
        delete[] readBuffer;
    readBuffer = NULL;
}

bool PVRLoader::LoadFromFile(const char* path)
{
    FreeReadBuffer();
    try {
        ifstream file;
        file.open(path);
        if(!file.good())
            return false;

        file.seekg(0, ios::end);
        int size = file.tellg();
        file.seekg(0, ios::beg);

        AllocReadBuffer(size);
        file.read(readBuffer, size);

        bool result = LoadFromMemory(readBuffer);
        if(!result)
            FreeReadBuffer();
        return result;
    } catch(...) {
        FreeReadBuffer();
        throw;
    }
}

bool PVRLoader::LoadFromMemory(const void* pData)
{
    const Header& header = *reinterpret_cast<const Header *>(pData);
    uint32_t      tag    = OSSwapLittleToHostInt32(header.tag);

    if(PVRIdentifier[0] != ((tag >>  0) & 0xff) ||
       PVRIdentifier[1] != ((tag >>  8) & 0xff) ||
       PVRIdentifier[2] != ((tag >> 16) & 0xff) ||
       PVRIdentifier[3] != ((tag >> 24) & 0xff))
    {
        return false;
    }

    uint32_t flags       = OSSwapLittleToHostInt32(header.flags);
    uint32_t formatFlags = flags & PVR_FLAG_TYPE_MASK;

    if(formatFlags == PVR_FLAG_TYPE_PVRTC_4 || formatFlags == PVR_FLAG_TYPE_PVRTC_2)
    {
        if(formatFlags == PVR_FLAG_TYPE_PVRTC_4)
            format = GL_COMPRESSED_RGBA_PVRTC_4BPPV1_IMG;
        else if(formatFlags == PVR_FLAG_TYPE_PVRTC_2)
            format = GL_COMPRESSED_RGBA_PVRTC_2BPPV1_IMG;
        else
            return false;

        width       = OSSwapLittleToHostInt32(header.width);
        height      = OSSwapLittleToHostInt32(header.height);
        hasAlpha    = OSSwapLittleToHostInt32(header.bitmaskAlpha) ? true : false;
        numSurfaces = 0;

        GLuint         w      = width;
        GLuint         h      = height;
        GLuint         offset = 0;
        GLuint         size   = OSSwapLittleToHostInt32(header.dataSize);
        const uint8_t* pBytes = reinterpret_cast<const uint8_t*>(pData) + sizeof(header);

        while(offset < size && numSurfaces < PVR_MAX_SURFACES)
        {
            GLuint   blockSize, widthBlocks, heightBlocks, bpp;
            Surface& surface = surfaces[numSurfaces++];

            if (formatFlags == PVR_FLAG_TYPE_PVRTC_4)
            {
                blockSize    = 4 * 4;
                widthBlocks  = w / 4;
                heightBlocks = h / 4;
                bpp = 4;
            }
            else
            {
                blockSize    = 8 * 4;
                widthBlocks  = w / 8;
                heightBlocks = h / 4;
                bpp = 2;
            }

            if (widthBlocks < 2)
                widthBlocks = 2;
            if (heightBlocks < 2)
                heightBlocks = 2;

            surface.size = widthBlocks * heightBlocks * ((blockSize  * bpp) / 8);
            surface.bits = &pBytes[offset];

            (w >>= 1) || (w = 1);
            (h >>= 1) || (h = 1);
            offset += surface.size;
        }

        return true;
    }
    else
    {
        return false;
    }
}

bool PVRLoader::Submit()
{
    if(numSurfaces <= 0)
        return false;

    GLuint w = width;
    GLuint h = height;

    for(GLuint i = 0 ; i < numSurfaces ; ++i)
    {
        const Surface& surface = surfaces[i];

        glCompressedTexImage2D(GL_TEXTURE_2D,
                               i,
                               format,
                               w,
                               h,
                               0,
                               surface.size,
                               surface.bits);
        w = (w >> 1) || 1;
        h = (h >> 1) || 1;
    }

    return true;
}

簡単に使い方を説明しておくと、まず PVRLoader クラスのインスタンスを作成し、 LoadFromFile メソッドで PVR ファイルを読み込み、 Submit メソッドで現在バインドされているテクスチャに画像を流し込みます。

// 画像ファイルを読み込む
PVRLoader loader;
loader.LoadFromFile("path/to/texture.pvr");

// テクスチャを作成し、画像を流し込む
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
loader.Submit();

また、 LoadFromMemory クラスを使うと、すでにメモリ上に読み込まれた PVR ファイルを使ってテクスチャを作成できます。ただし、この場合は Submit 時に指定されたメモリ上のデータを直接参照するので、それまでは PVR ファイルイメージを保持したメモリを解放しないでください。

アプリケーションでテクスチャを読み込む

それでは、上記のクラスを使って GLSample1 で PVRTC 圧縮テクスチャを表示してみましょう。まずは ES1Renderer.m 内で C++ コードを記述できるようにするため、 .m から .mm に拡張子を変更します。コンテキストメニューの「名称変更」を使うのが手っ取り早いです。

そして ES1Renderer.mm を開き、まず最初に PVRLoader.h を import します。 ES1Renderer.h の次にインポートするのが良いでしょう。

#import "ES1Renderer.h"
#import "PVRLoader.h"

// 深度バッファのハンドルを格納するグローバル関数

あとは、 loadTexture 関数で PNG ファイルの代わりに PVR ファイルを読み込みます。

static void loadTexture() {
    // テクスチャを作成
    glGenTextures(1, &earthTexture);
    glBindTexture(GL_TEXTURE_2D, earthTexture);

    // 画像ファイルを読み込む
    PVRLoader loader;
    loader.LoadFromFile([[[NSBundle mainBundle] pathForResource:@"earth" ofType:@"pvr"] UTF8String]);
    loader.Submit();
    glBindTexture(GL_TEXTURE_2D, 0);
}

これだけで OK です。実際にビルドして確認してみてください。

バッチリ表示できました。画質の劣化もほとんどわかりませんね。

以上、本日は iPhone でのテクスチャ圧縮の利用方法についてご紹介しました。テクスチャ圧縮を使うだけでアプリケーションが省メモリかつ高速になり、さらに画像の読み込み処理も PNG などより軽いので、起動時間短縮にも繋がるでしょう。このように多くのメリットがあるテクスチャ圧縮、ぜひ活用してください!


2009/11/18 追記 :
サンプルコードを iPhone OS 3.0 SDK のプロジェクト・テンプレートに対応させました。

2011/5/6 追記 :
PVRLoader.cc のバグを修正しました。

関連記事

この記事にコメントする

Recommendations
Books
「Closure Library」の入門書です。
詳しくはこちらの記事をどうぞ!
Categories
Recent Articles