Crunchテクスチャを試す

WebGL アドベントカレンダー 20日目です。

## 圧縮テクスチャの話
WebGLではしばしば画像フォーマットとしてPNGやJPEGなどブラウザ用の画像フォーマットが用いられます。これらは圧縮率に優れており、画像ライブラリなどが不要で使い勝手が良いです。[^1]
一方でこれらはWebGLのAPIから見れば圧縮のフォーマットであるとは言えません。GPUはPNGやJPEGといったブラウザ用のフォーマットをそのまま読むことはできません。そのため、GPUに転送するタイミングでRGBA32のような非圧縮データに変換されてしまいます。
そこでGPU用のテクスチャフォーマットであるDXTCやETC2などを使用します。圧縮率はPNGやJPEGといったブラウザ用の画像フォーマットには劣るものの、圧縮されたデータのままGPUにアップロードできるためメモリ使用量を節約可能で、パフォーマンスにも優れています。
ただし、これらはOpenGL ES 2.0においては拡張機能であり、すべてのプラットフォームで使用できるというわけではありません。原則DXTCはPCでしか使用できませんし、ETC2はAndroidでしか使用できません。
さて、*OpenGL ES 2.0では* と表現しましたが、*OpenGL ES 3.0* では拡張を使用せずともETC2フォーマットが使用可能です。しかし、私はOpenGL ES 3.0に相当するWebGL 2.0では拡張を使用せずともETC2を使用できると思っていたのですが、[WebGL 2.0では標準仕様としては組み込まれてはいませんでした](https://www.khronos.org/registry/webgl/specs/latest/2.0/#5.37)。
結果圧縮テクスチャはWebGL 2.0の時代においても拡張に頼る必要があります。

## Crunch

説明したようにETC2やDXTCといった圧縮フォーマットはメモリが節約できる一方でファイルサイズがJPEGやPNGなどに比べて比較的大きくなります。
これら圧縮テクスチャをCPUにて更に圧縮したものがCrunchです。
もともとは[Binomial](http://www.binomial.info/)によってメンテナンスされていてDXTCを圧縮できるライブラリであったのですがこれを[UnityがETC2でも使用できるように改良しました](https://blogs.unity3d.com/jp/2017/11/15/updated-crunch-texture-compression-library/)。
[GitHubにてソースコードが公開されている](https://github.com/Unity-Technologies/crunch/tree/unity)のでこちらをEmscriptenでビルドしてWebGLで使用します。

レポジトリには圧縮ツールとしてビルド済みのcrunch_x64.exeが含まれているので、こちらを利用して圧縮します。
出力されるファイルはcrnファイルで、ETC2に圧縮するならば`-etc2`オプション、DXT1であれば`-dxt1`オプションを追加します。
その他オプションもいろいろ指定していますが、 `-yflip` を指定しないと画像が上下反転してしまうので忘れずに指定します。
元画像ファイルが1024x1024(3.172 bits / pixel)のJPEGファイルで、出力結果によるとETC2は1.516 bits / pixel, DXT1は1.291 bits / pixelの圧縮率で、それぞれ展開後は4 bits / pixelとなります。

```:圧縮
>crunch_x64.exe -fileformat crn -yflip -quality 255 -maxmips 1 -dxt1 -file assets\jinja.jpg
crunch: Advanced DXTn Texture Compressor - https://github.com/BinomialLLC/crunch
Copyright (c) 2010-2016 Richard Geldreich, Jr. and Binomial LLC
crnlib version v1.04 x64 Built Oct 23 2018, 22:18:21

Reading source texture: "assets\jinja.jpg"
Texture successfully loaded in 0.028s
Source texture: 1024x1024, Levels: 1, Faces: 1, Format: R8G8B8
Apparent type: 2D map, Flags: R G B Non-Flipped
Flipping texture on Y axis
Generating mipmaps using filter "kaiser"
Generated 0 mipmap levels in 0.000s
Writing DXT1 texture to file: "jinja.crn"
Compressing using quality level 255
Processing: 100%
Texture successfully written in 1.716s
Texture successfully processed in 1.727s
Input texture: 1024x1024, Levels: 1, Faces: 1, Format: R8G8B8
Input pixels: 1048576, Input file size: 415714, Input bits/pixel: 3.172
Output texture: 1024x1024, Levels: 1, Faces: 1, Format: DXT1
Output pixels: 1048576, Output file size: 169202, Output bits/pixel: 1.291

Total time: 1.774s
1 total file(s) successfully processed, 0 file(s) skipped, 0 file(s) failed.

Exit status: 0

>crunch\bin\crunch_x64.exe -fileformat crn -yflip -quality 255 -maxmips 1 -etc2 -file assets\jinja.jpg
crunch: Advanced DXTn Texture Compressor - https://github.com/BinomialLLC/crunch
Copyright (c) 2010-2016 Richard Geldreich, Jr. and Binomial LLC
crnlib version v1.04 x64 Built Oct 23 2018, 22:18:21

Reading source texture: "assets\jinja.jpg"
Texture successfully loaded in 0.029s
Source texture: 1024x1024, Levels: 1, Faces: 1, Format: R8G8B8
Apparent type: 2D map, Flags: R G B Non-Flipped
Flipping texture on Y axis
Generating mipmaps using filter "kaiser"
Generated 0 mipmap levels in 0.000s
Writing ETC2 texture to file: "jinja.crn"
Compressing using quality level 255
Processing: 100%
Texture successfully written in 2.599s
Texture successfully processed in 2.611s
Input texture: 1024x1024, Levels: 1, Faces: 1, Format: R8G8B8
Input pixels: 1048576, Input file size: 415714, Input bits/pixel: 3.172
Output texture: 1024x1024, Levels: 1, Faces: 1, Format: ETC2
Output pixels: 1048576, Output file size: 198738, Output bits/pixel: 1.516

Total time: 2.656s
1 total file(s) successfully processed, 0 file(s) skipped, 0 file(s) failed.

Exit status: 0

```

## WebGLで使用する

WebGLではcrunch_lib.cppのみを使用するため、[cpp-wasm-loader](https://github.com/ClickSimply/cpp-wasm-loader)でJavaScriptから直接CrunchのC++ファイルをインポートします。
基本はGitHubにあるCrunchのビルド方法と同じオプションを指定しますが、stdint.hをインクルードしないとビルドできなかったためオプションを追加しています。

```js:webpack.config.js
{
  test: /crunch_lib\.cpp$/,
  use: {
    loader: "cpp-wasm-loader",
    options: {
      emccFlags: (existingFlags) => {
        const flags = existingFlags.filter(x => !/^-O\d$/.test(x));

        Array.prototype.push.call(
          flags,
          "-I./inc",
          "-include", "stdint.h",
          "-s", "EXPORTED_FUNCTIONS=['_malloc', '_free', '_crn_get_width', '_crn_get_height', '_crn_get_levels', '_crn_get_dxt_format', '_crn_get_bytes_per_block', '_crn_get_uncompressed_size', '_crn_decompress']",
          "-s", "NO_EXIT_RUNTIME=1",
          "-s", "NO_FILESYSTEM=1",
          "-s", "ELIMINATE_DUPLICATE_FUNCTIONS=1",
          "-s", "ALLOW_MEMORY_GROWTH=1",
          "--memory-init-file", "0"
        );

        if (process.env.NODE_ENV === "production") {
          // 最適化用
          Array.prototype.push.call(
            flags,
            "-g0",
            "-O2"
          );
        } else {
          // デバッグ用
          Array.prototype.push.call(
            flags,
            "-g4",
            "-O0"
          );
        }
        return flags;
      },
      fullEnv: true
    }
  }
}
```

画像データはmallocで確保したメモリでないと使用できないようなので、fetchで取得した画像をmallocで確保したメモリにコピーしてやる必要があります。

```js
import * as crunch from "./crunch/emscripten/crunch_lib.cpp";

crunch.init().then((module) => {
    const memory = module.memory;

    const crn_get_width = module.exports.crn_get_width;
    const crn_get_height = module.exports.crn_get_height;
    const crn_get_levels = module.exports.crn_get_levels;
    const crn_get_uncompressed_size = module.exports.crn_get_uncompressed_size;
    const crn_decompress = module.exports.crn_decompress;

    const malloc = module.exports.malloc;
    const free = module.exports.free;

    fetch("./assets/jinja.crn").then((response) => response.arrayBuffer()).then((bytes) => {
      //画像データをコピー
      const src = malloc(bytes.byteLength);
      new Uint8Array(memory, src, srcSize).set(
        new Uint8Array(bytes),
      );

      const width = crn_get_width(src, srcSize);
      const height = crn_get_height(src, srcSize);

      // 画像データを確保
      const dstSize = crn_get_uncompressed_size(src, srcSize, 0);
      const dst = malloc(dstSize);
      // 展開
      crn_decompress(src, srcSize, dst, dstSize, 0, 1);

      const image = new Uint8Array(memory, dst, dstSize);

      const s3tc = gl.getExtension( "WEBGL_compressed_texture_s3tc");
      const etc = gl.getExtension("WEBGL_compressed_texture_etc");

      const tex = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, tex);

      // texImage2Dではなく、compressedTexImage2Dを使用します。
      gl.compressedTexImage2D(
        gl.TEXTURE_2D,
        0,
        dxtc.COMPRESSED_RGBA_S3TC_DXT1_EXT, // ETC2の場合はetc.COMPRESSED_RGB8_ETC2
        width,
        height,
        0,
        image,
      );
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    });
})
```

## 実行結果
JPEG
![JPEG](https://qiita-image-store.s3.amazonaws.com/0/5907/2df8fc93-0d19-6480-0c13-01b51cec866d.png)

DXT1
![dxt1.png](https://qiita-image-store.s3.amazonaws.com/0/5907/6dc835b1-f53f-b866-05f2-c57c58e9bbf7.png)

ETC2
![etc2.png](https://qiita-image-store.s3.amazonaws.com/0/5907/1a532169-a41c-b9cc-dc2d-c85a756a1684.png)

[ソース](https://github.com/nyamadan/crunched_test)と[デモ](https://nyamadan.github.io/crunched_test/)
DXT1とETC2で圧縮しました。ETC2はAndroidスマートフォン、DXT1はPCで見ることができると思います。

## 参考
[【Unity】iOSでもETC2が使えるようになった。 - テラシュールブログ](http://tsubakit1.hateblo.jp/entry/2017/11/22/001941)
[3Dコンテンツの最適化に。圧縮テクスチャーをWebGLで扱う方法と利点 - ICS MEDIA](https://ics.media/entry/17863)

[^1]: ただし、OpenGLで用いられるテクスチャ座標は上方向に正であるため、しばしば `gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)` などと上下反転を行います。