koturnの日記

転職したい社会人2年生の技術系日記.ブログ上のコードはコピペ自由です.

カスタムレンダーテクスチャでライフゲームを実装した

TL;DR

カスタムレンダーテクスチャでライフゲームを実装してVRChatのアバターに付けた.

f:id:koturn:20210410201058g:plain
ライフゲーム

背景

オタクはすぐにシェーダでライフゲームを実装するもの,らしい.

N番煎じではあるが,VRChatのアバターライフゲーム付けるのはすぐにできるなと思ったので実装した. カスタムレンダーテクスチャは1つ前の状態を取れるので,ライフゲームの実装をするのにもってこいというわけである.

初めてシェーダを書いたが,作る中で色々と学びがあったので,記事を書くことにした.

作り

  1. ランダムな初期化を行わない
  2. 初期状態は画像ファイルを与える
  3. 振動子や移動物体等の永遠に生存するもののみを対象にする
  4. アルファ値0.0を死滅状態,それ以外を生存状態とする(生存状態のアルファ値は自由に設定可能)

初期化画像

初期化画像として下記のような死滅セルはアルファ値0,生存セルは色付き(下記の例では RGBA = (0, 255, 0, 255))で1bpp(2色パレット)のPNG画像を採用した.

f:id:koturn:20210411180245p:plain
初期化画像(Galaxyパターン)

1bppのPNG画像を採用した理由としては以下の3点である.

  • ライフゲームでは2値あれば十分である
  • 2値画像を表現する際,1bppパレット形式が基本的に最小データサイズの表現形式である(※)
  • パレット形式のPNG画像なら編集できるドット絵ツールがある

※IDATチャンクが十分に小さいとき(画像の縦と横のサイズが十分に小さいなど),各チャンクのヘッダとパレット自身のサイズが占める割合が多くなるため,32bppARGB形式の方が小さくなる場合もある

ただし,初期化画像で参照するのは,あくまでアルファ値のみなので,死滅セルのアルファ値が0であれば セルの色自体はシェーダにプロパティを設け,カスタムレンダーテクスチャ用のマテリアルのインスペクタを通じて設定できるようにした.

また,上記の例のテクスチャ画像の縦横のサイズは16x16 pixelである. これは4の倍数かつ2のべき乗のサイズでGalaxyパターンが繰り返し可能な最小の画像サイズのためである.

初期化画像の設定

画像がかなり小さいサイズであるのと,ピクセルをハッキリさせたいので,Filter ModeはPoint (no filter)にする.

f:id:koturn:20210410195928p:plain
初期化画像の設定

シェーダーの全容

ライフゲーム用のカスタムレンダーテクスチャのシェーダは下記の通り.

Shader "koturn/GameOfLife"
{
    Properties
    {
        // shader_feature: _CUTOUTSIDE_ON
        [Toggle]
        _CutOutside ("Cut outside of texture; Treat as zero outside texels", Float) = 0

        _Color ("Cell Color", Color) = (0.0, 1.0, 0.0, 1.0)
    }
    SubShader
    {
        ZTest Always
        ZWrite Off
        Lighting Off

        Pass
        {
            Name "Update"

            CGPROGRAM
            #pragma target 3.0

            #include "UnityCustomRenderTexture.cginc"

            #pragma vertex CustomRenderTextureVertexShader
            #pragma fragment frag
            #pragma shader_feature _ _CUTOUTSIDE_ON

            //! 浮動小数点演算誤差許容範囲
            static const float eps = 1.0e-3;
            //! 全要素1のベクトル
            static const float3 ones3 = float3(1.0, 1.0, 1.0);
            //! 生存セルの色
            uniform float4 _Color;

#ifdef _CUTOUTSIDE_ON
            /*!
             * @brief テクスチャ座標外の指定時は(0.0, 0.0, 0.0, 0.0)を返すtex2Dを行う
             * @param [in] tex テクスチャサンプラー
             * @param [in] uv  UV座標
             * @return テクスチャ座標外の指定時は(0.0, 0.0, 0.0, 0.0),テクスチャ座標内はテクセルの色
             */
            inline float4 tex2DCutOutside(sampler2D tex, float2 uv) {
                const float2 v = step(0.0, uv) * step(uv, 1.0);
                return v.x * v.y * tex2D(tex, uv);
            }
#endif  // _CUTOUTSIDE_ON
            /*!
             * @brief フラグメントシェーダ
             * @param [in] i  カスタムレンダーテクスチャの入力値
             * @return 1つのテクセルに対するRGBA値
             */
            float4 frag(v2f_customrendertexture i) : COLOR
            {
                const float2 d = float2(1.0 / _CustomRenderTextureWidth, 1.0 / _CustomRenderTextureHeight);
                const float2 uv = i.globalTexcoord;

#ifdef _CUTOUTSIDE_ON
                const float3x3 neighbor3x3 = step(_Color.a, float3x3(
                    tex2DCutOutside(_SelfTexture2D, uv - d).a,
                    tex2DCutOutside(_SelfTexture2D, float2(uv.x, uv.y - d.y)).a,
                    tex2DCutOutside(_SelfTexture2D, float2(uv.x + d.x, uv.y - d.y)).a,
                    //
                    tex2DCutOutside(_SelfTexture2D, float2(uv.x - d.x, uv.y)).a,
                    0.0,
                    tex2DCutOutside(_SelfTexture2D, float2(uv.x + d.x, uv.y)).a,
                    //
                    tex2DCutOutside(_SelfTexture2D, float2(uv.x - d.x, uv.y + d.y)).a,
                    tex2DCutOutside(_SelfTexture2D, float2(uv.x, uv.y + d.y)).a,
                    tex2DCutOutside(_SelfTexture2D, uv + d).a));
#else
                const float3x3 neighbor3x3 = step(_Color.a, float3x3(
                    tex2D(_SelfTexture2D, uv - d).a, tex2D(_SelfTexture2D, float2(uv.x, uv.y - d.y)).a, tex2D(_SelfTexture2D, float2(uv.x + d.x, uv.y - d.y)).a,
                    tex2D(_SelfTexture2D, float2(uv.x - d.x, uv.y)).a, 0.0, tex2D(_SelfTexture2D, float2(uv.x + d.x, uv.y)).a,
                    tex2D(_SelfTexture2D, float2(uv.x - d.x, uv.y + d.y)).a, tex2D(_SelfTexture2D, float2(uv.x, uv.y + d.y)).a, tex2D(_SelfTexture2D, uv + d).a));
#endif  // _CUTOUTSIDE_ON
                const float sum = mul(mul(ones3, neighbor3x3), ones3);

                const float a = _Color.a * (step(abs(sum - 3.0), eps)
                    + step(_Color.a, tex2D(_SelfTexture2D, uv).a) * step(abs(sum - 2.0), eps));

                return float4(_Color.rgb, a);
            }
            ENDCG
        }
    }
}

カスタムレンダーテクスチャの設定

f:id:koturn:20210410195930p:plain
カスタムレンダーテクスチャの設定

Wrap Mode

Repeatにすると,領域外を指定したときに反対側の座標を指定したことになる. これについては後述する.

Filter Mode

初期化画像と同様,Pointを指定する.

Initialization Mode

Initialization ModeはOn Load,SourceはTexture and Colorで,Textureに初期化用のPNG画像を指定する.

Update Mode

Update ModeはRealtimeでDouble Bufferedにチェックを入れる. Double Bufferedにチェックを入っていれば,Dimensionに応じたサンプラー_SelfTexture2D_SelfTexture3D_SelfTexture3D_SelfTextureCubeを利用することで,1つ前のテクスチャの状態を取得することができる.

Periodは更新周期に該当する.グライダーガン系は更新頻度高めで,周期が短めのものは更新頻度低めにするといい感じだと思う.

領域外の扱い

領域外については,下記の2つのどちらかで扱うのが一般的である.

  1. 領域外を死滅セルとして扱う
  2. 領域の端と端をくっつけて,RPGの世界のようなトーラス型の世界として扱う

必要に応じてどちらかを使えるかを選択できるようにした(shader_feature を利用).

f:id:koturn:20210410195945p:plain
カスタムレンダーテクスチャマテリアルの設定

実は2.については,カスタムレンダーテクスチャ自体の設定でWrap Modeを「Repeat」にしていれば,tex2D() で領域外を参照したときに達成できる(後述).

1.については,計算上,多少の工夫が必要となる. tex2D と同様に使える関数として下記のものを用意した.

inline float4 tex2DCutOutside(sampler2D tex, float2 uv) {
    float2 v = step(0.0, uv) * step(uv, 1.0);
    return v.x * v.y * tex2D(tex, uv);
}

step(0.0, uv) * step(uv, 1.0) がポイントで, 0.0 <= uv.x && uv.x <= 1.0 であれば uv.x1.0,そうでなければ 0.0 となる. uv.y に関しても同様である. 従って, uv.x * uv.yuv が領域内のテクスチャ座標なら 1.0,領域外なら 0.0 となるので,これを tex2D() に掛ける.

行列の要素の和

単純に全要素の和を求めてもよかったが,全要素1のベクトルと行列の積を利用して,スマートに求めた. 下記の部分が該当する.

const float sum = mul(mul(ones3, neighbor3x3), ones3);

アセンブリ命令としては,dp3というベクトルの内積を求める命令の組み合わせに変換されるようなので,単純に8回加算するより効率が良い可能性がある(実行時間の計測はしていないので断言できない). 算数的には式(\ref{eq:MatrixSum})の計算を行っているだけである.

\begin{eqnarray} \begin{pmatrix} 1 & 1 & 1 \end{pmatrix} \begin{pmatrix} a & b & c \\ d & e & f \\ g & h & i \end{pmatrix} \begin{pmatrix} 1 \\ 1 \\ 1 \end{pmatrix} & = & \begin{pmatrix} a + d + g & b + e + h & c + f + i \end{pmatrix} \begin{pmatrix} 1 \\ 1 \\ 1 \end{pmatrix} \nonumber \\ & = & (a + d + g) + (b + e + h) + (c + f + i) \nonumber \\ & = & a + b + c + d + e + f + g + h + i \label{eq:MatrixSum} \end{eqnarray}

更新部分

最も単純な実装

ライフゲームのルールの文面,

  • 誕生: 死んでいるセルに隣接する生きたセルがちょうど3つあれば,次の世代が誕生する.
  • 生存: 生きているセルに隣接する生きたセルが2つか3つならば,次の世代でも生存する.
  • 過疎: 生きているセルに隣接する生きたセルが1つ以下ならば,過疎により死滅する.
  • 過密: 生きているセルに隣接する生きたセルが4つ以上ならば,過密により死滅する.

に従った最もナイーブな更新方法は以下の通り.

texel = tex2D(_SelfTexture2D, uv);
if (texel.a < _Color.a) {
    if (abs(sum - 3.0) < eps) {
        texel.a = _Color.a;
    }
} else if (1.5 < sum && sum < 3.5) {
    texel.a = _Color.a;
} else {
    texel.a = 0.0;
}
return texel;

更新ルールを読み替えた実装

しかし,更新ルールの文面をよく読むと,真っ先に現在のセルの生存判定を行なう必要がないことがわかり,下記のように読みかえることができる.

  • 隣接する生きたセルがちょうど3つあれば,次の世代は生存状態
  • 隣接する生きたセルが2つでなければ,次の世代は死滅状態
  • それ以外は前の状態から変化しない
if (abs(sum - 3.0) < eps) {
    return _Color;
} else if (abs(sum - 2.0) > eps) {
    return float4(_Color.rgb, 0.0);
} else {
    // 状態変化無しだが,初期化画像の上書きのため,_Colorの値を用いる
    return float4(_Color.rgb, step(_Color.a, tex2D(_SelfTexture2D, uv).a) * _Color.a);
}

条件分岐を削除した実装

このコードをよく見ると,条件分岐は変数 sum に関するものであり,各条件は直行していることが見てとれる. なので,ある条件を満たすときのみその値になるように書き換えることができる.

論理和論理積の代わりに加算と乗算を用いて,下記のように修正すると等価な計算となる. (GPUでは条件分岐は避けるべきという話なので,なるべく条件分岐は削除したい)

float a = _Color.a * (float(abs(sum - 3.0) < eps)
    + step(_Color.a, tex2D(_SelfTexture2D, uv).a) * float(abs(sum - 2.0) < eps));
return float4(_Color.rgb, a);

実際に生成されるアセンブリコードを確認すると,条件分岐を消去できていることを確認できた.

Shader Disassembly:
//
// Generated by Microsoft (R) D3D Shader Disassembler
//
//
// Input signature:
//
// Name                 Index   Mask Register SysValue  Format   Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_POSITION              0   xyzw        0      POS   float
// TEXCOORD                 0   xyz         1     NONE   float
// TEXCOORD                 1   xyz         2     NONE   float   xy
// TEXCOORD                 2   x           3     NONE    uint
// TEXCOORD                 3   xyz         4     NONE   float
//
//
// Output signature:
//
// Name                 Index   Mask Register SysValue  Format   Used
// -------------------- ----- ------ -------- -------- ------- ------
// SV_Target                0   xyzw        0   TARGET   float   xyzw
//
      ps_4_0
      dcl_constantbuffer CB0[53], immediateIndexed
      dcl_sampler s0, mode_default
      dcl_resource_texture2d (float,float,float,float) t0
      dcl_input_ps linear v2.xy
      dcl_output o0.xyzw
      dcl_temps 5
   0: mov r0.y, v2.y
   1: div r1.xyzw, l(1.000000, 1.000000, 1.000000, 1.000000), cb0[51].xyxy
   2: add r2.xyzw, r1.xzyw, v2.xxyy
   3: add r1.xy, -r1.zwzz, v2.xyxx
   4: mov r1.w, r2.x
   5: mov r0.x, r1.w
   6: sample r3.xyzw, r1.wyww, t0.wxyz, s0
   7: sample r0.xyzw, r0.xyxx, t0.xyzw, s0
   8: mov r3.y, r0.w
   9: sample r0.xyzw, r2.ywyy, t0.xyzw, s0
  10: mov r3.z, r0.w
  11: ge r0.xyz, r3.xyzx, cb0[52].wwww
  12: and r0.xyz, r0.xyzx, l(0x3f800000, 0x3f800000, 0x3f800000, 0)
  13: dp3 r0.z, l(1.000000, 1.000000, 1.000000, 0.000000), r0.xyzx
  14: mov r2.x, r1.x
  15: sample r3.xyzw, r2.xzxx, t0.xywz, s0
  16: sample r4.xyzw, r1.xyxx, t0.xyzw, s0
  17: mov r3.x, r4.w
  18: mov r2.yw, v2.yyyx
  19: sample r4.xyzw, r2.xyxx, t0.xyzw, s0
  20: sample r2.xyzw, r2.wzww, t0.xywz, s0
  21: mov r3.y, r4.w
  22: ge r3.xyz, r3.xyzx, cb0[52].wwww
  23: and r3.xyz, r3.xyzx, l(0x3f800000, 0x3f800000, 0x3f800000, 0)
  24: dp3 r0.x, l(1.000000, 1.000000, 1.000000, 0.000000), r3.xyzx
  25: mov r1.z, v2.x
  26: sample r1.xyzw, r1.zyzz, t0.xyzw, s0
  27: mov r2.x, r1.w
  28: mov r2.y, l(0)
  29: ge r1.xyz, r2.xyzx, cb0[52].wwww
  30: and r1.xyz, r1.xyzx, l(0x3f800000, 0x3f800000, 0x3f800000, 0)
  31: dp3 r0.y, l(1.000000, 1.000000, 1.000000, 0.000000), r1.xyzx
  32: dp3 r0.x, r0.xyzx, l(1.000000, 1.000000, 1.000000, 0.000000)
  33: add r0.xy, r0.xxxx, l(-3.000000, -2.000000, 0.000000, 0.000000)
  34: ge r0.xy, l(0.001000, 0.001000, 0.000000, 0.000000), |r0.xyxx|
  35: sample r1.xyzw, v2.xyxx, t0.xyzw, s0
  36: ge r0.z, r1.w, cb0[52].w
  37: and r0.xyz, r0.xyzx, l(0x3f800000, 0x3f800000, 0x3f800000, 0)
  38: mad r0.x, r0.z, r0.y, r0.x
  39: mul o0.w, r0.x, cb0[52].w
  40: mov o0.xyz, cb0[52].xyzx
  41: ret
// Approximately 0 instruction slots used

boolからfloatへのキャスト

シェーダではbool型からfloat型へのキャストが許されている. true であれば 1.0 を,false であれば 0.0 を返すようになっているようだ.

シェーダアセンブリでは,比較命令およびその結果と 0x3f800000 の and を取っている部分が該当する. 比較命令は結果が真であれば 0xffffffff を格納し,偽であれば 0x00000000 を格納するらしい. それと 0x3f800000 との and を取れば1.0か0.0になるカラクリのようだ(IEEE 754では).

step()abs() に関しても比較演算結果によって戻り値が異なるだけなので,0x3f800000 と and を取っている部分に化けていると思われる.

特に step() は式(\ref{eq:StepFunction})の計算を行うだけなので,比較結果を bool にキャストすることに他ならない.

\begin{equation} step(a, x) = \begin{cases} 0 & (x < a) \\ 1 & (x \geq a) \end{cases} \label{eq:StepFunction} \end{equation}

浮動小数点数の等値比較

IEEE 754では,例えば1.0を8回加算したとしても8.0になるだけだが,GPUIEEE 754を採用しているとも限らないので,2数の絶対値の差が小さい正の数 eps より小さければ等しいとして,安全な方に倒している.

色相変化

単なるライフゲームではつまらないので,時間とともに色相を変化させようと考えた.

カスタムレンダーテクスチャは別のカスタムレンダーテクスチャへの入力に利用することが可能である. そのため,色相の変化を1つのカスタムレンダーテクスチャで行うのではなく,別のカスタムレンダーテクスチャで行うことにした. こうすることで,ライフゲーム自体の更新頻度に関わらず,色相変化を行うことが可能になる.

シェーダーの全容

色相変化用のカスタムレンダーテクスチャのシェーダは下記の通り.

Shader "koturn/HueRotation"
{
    Properties
    {
        // shader_feature: _HUEONLY_ON
        [Toggle]
        _HueOnly ("Treats Hue only, ignore offset of Saturation and Value", Float) = 0

        _MainTex ("Source Texture", 2D) = "white" {}
        _TimeScale ("Time multiplier for HSV rotation", Float) = 0.1
        _HueOffset ("Offset of Hue (H)", Range(0.0, 1.0)) = 0.0
        _SaturationOffset ("Offset of Saturation (S)", Range(-1.0, 1.0)) = 0.0
        _ValueOffset ("Offset of Value (V)", Range(-1.0, 1.0)) = 0.0
    }
    SubShader
    {
        ZWrite Off
        ZTest Always
        Lighting Off

        CGINCLUDE
        #pragma target 3.0

        #include "UnityCustomRenderTexture.cginc"

        #pragma vertex CustomRenderTextureVertexShader

        UNITY_DECLARE_TEX2D(_MainTex);
        ENDCG

        Pass
        {
            Name "Nothing"

            CGPROGRAM
            #pragma fragment frag
            /*!
             * @brief フラグメントシェーダ
             *
             * 指定されたUV座標のテクセルの値をそのまま返す
             *
             * @param [in] i  カスタムレンダーテクスチャの入力値
             * @return 1つのテクセルに対するRGBA値
             */
            float4 frag(v2f_customrendertexture i) : COLOR
            {
                return UNITY_SAMPLE_TEX2D(_MainTex, i.globalTexcoord);
            }
            ENDCG
        }

        Pass
        {
            Name "Update"

            CGPROGRAM
            #pragma fragment frag
            #pragma shader_feature _ _HUEONLY_ON

            inline float3 rgb2hsv(float3 rgb);
            inline float3 hsv2rgb(float3 hsv);

            uniform float _TimeScale;
            uniform float _HueOffset;
#ifndef _HUEONLY_ON
            uniform float _SaturationOffset;
            uniform float _ValueOffset;
#endif  // !_HUEONLY_ON
            /*!
             * @brief フラグメントシェーダ
             *
             * @param [in] i  カスタムレンダーテクスチャの入力値
             * @return 1つのテクセルに対するRGBA値
             */
            float4 frag(v2f_customrendertexture i) : COLOR
            {
                const float4 texel = UNITY_SAMPLE_TEX2D(_MainTex, i.globalTexcoord);
                float3 hsv = rgb2hsv(texel.rgb);
#ifdef _HUEONLY_ON
                hsv.x += _Time.y * _TimeScale + _HueOffset;
#else
                hsv = float3(
                    hsv.x + _Time.y * _TimeScale + _HueOffset,
                    saturate(hsv.y + _SaturationOffset),
                    saturate(hsv.z + _ValueOffset));
#endif  // _HUEONLY_ON
                return float4(hsv2rgb(hsv), texel.a);
            }

            /*!
             * @brief RGB色空間からHSV色空間へ写像を行なう
             *
             * 入力のRGBの各要素は閉区間: [0.0, 1.0] の範囲になければならない
             *
             * @param [in] rgb  RGB値
             * @return HSV値
             */
            inline float3 rgb2hsv(float3 rgb)
            {
                static const float4 k = float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
                static const float e = 1.0e-10;

                const float4 p = rgb.g < rgb.b ? float4(rgb.bg, k.wz) : float4(rgb.gb, k.xy);
                const float4 q = rgb.r < p.x ? float4(p.xyw, rgb.r) : float4(rgb.r, p.yzx);
                const float d = q.x - min(q.w, q.y);
                return float3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
            }

            /*!
             * @brief HSV色空間からRGB色空間へ写像を行なう
             *
             * 入力のHSVのSV要素は閉区間: [0.0, 1.0] の範囲になければならない
             *
             * @param [in] hsv  HSV値
             * @return RGB値
             */
            inline float3 hsv2rgb(float3 hsv)
            {
                static const float4 k = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);

                const float3 p = abs(frac(hsv.xxx + k.xyz) * 6.0 - k.www);
                return hsv.z * lerp(k.xxx, saturate(p - k.xxx), hsv.y);
            }
            ENDCG
        }
    }
}

RGBからHSVへの変換,その逆に関しては先人に知恵を借りた. RGBからHSVに変換するよく知られた実装では条件分岐が含まれるので,先人のシェーダ用の賢い実装は参考になる.

inline float3 rgb2hsv(float3 rgb)
{
    static const float4 k = float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
    static const float e = 1.0e-10;

    const float4 p = rgb.g < rgb.b ? float4(rgb.bg, k.wz) : float4(rgb.gb, k.xy);
    const float4 q = rgb.r < p.x ? float4(p.xyw, rgb.r) : float4(rgb.r, p.yzx);
    const float d = q.x - min(q.w, q.y);
    return float3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

inline float3 hsv2rgb(float3 hsv)
{
    static const float4 k = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);

    const float3 p = abs(frac(hsv.xxx + k.xyz) * 6.0 - k.www);
    return hsv.z * lerp(k.xxx, saturate(p - k.xxx), hsv.y);
}

色相変化の速度

Unityのシェーダでは経過時間(秒)をfloat値として取得できる. 特にこだわりはないので,素直に扱える経過時間に係数がかかっていないもの(_Time.y)を利用する.

インスペクタから色相の変化速度を設定できるようにしたいので,_Time.y に対する係数のプロパティ _TimeScale を用意する. ついでに初期色相位相も指定できるとベンリだと思ったので,_HueOffset というプロパティも用意する.

hsv.x += _Time.y * _TimeScale + _HueOffset;

このままだと色相値が [0.0, 1.0] に収まっていないが,hsv2rgb() の中で frac() 関数を通しているので問題はない. frac関数とは,小数部を返す関数であり,わかりやすく表現すると式(\ref{eq:FracFunction})となる.

\begin{equation} frac(x) = x - \lfloor x \rfloor \label{eq:FracFunction} \end{equation}

例えば frac(0.3) == 0.3frac(1.1) == 0.1frac(123.4) == 0.4 となる. 0.0 ~ 1.0で循環する動きをするため,色相値に対して利用するのにもってこいの関数である.

なお,もののついでとして,SVのオフセットも指定できるようにした. ただし,ほとんどの場合利用することはないと考え,shader_feature で利用しないコード生成もできるようにした.

カスタムレンダーテクスチャの貼り付け

たかがライフゲームなので,適当にQuadを用意して貼り付けるようにした. ただし,両面から見えるようにカリングはオフにした方が良いと思う.

Standard Shaderではカリングの設定はできないので,

  1. Standard Shaderのコードを入手し,Cull Off を加えたものを利用する
  2. MToonなどのカリングの指定ができるシェーダを利用する
  3. 適当なシェーダを書く

のいずれかで対応するとよい.

適当なシェーダとは,例えば下記のような超ミニマルなサーフェースシェーダ等でもよいと思う.

Shader "koturn/SimpleSurface"
{
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0, 1)) = 0.5
        _Metallic ("Metallic", Range(0, 1)) = 0.0
    }
    SubShader
    {
        Tags
        {
            "RenderType" = "Transparent"
            "Queue" = "AlphaTest"
        }
        LOD 200
        Blend SrcAlpha OneMinusSrcAlpha
        ZWrite Off

        CGPROGRAM

        #pragma surface surf Standard fullforwardshadows alphatest:fade

        #pragma target 3.0

        UNITY_DECLARE_TEX2D(_MainTex);

        struct Input
        {
            float2 uv_MainTex;
        };

        uniform half _Glossiness;
        uniform half _Metallic;

        void surf(Input IN, inout SurfaceOutputStandard o)
        {
            fixed4 c = UNITY_SAMPLE_TEX2D(_MainTex, IN.uv_MainTex);
            o.Albedo = c.rgb;
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

問題点

原因はよくわからないが,VRChatだとカスタムレンダーテクスチャの更新が止まることがある. 特に複数人いる場合,ワールドが重い場合にその傾向があるように思う.

自分視点だと停止することが多いが,他の人視点だと動作していることが多いので,あまり気にしないことにしている.

また,色相変化に _Time を用いているが,float 値であるため,十分に長い時間が経過すると,色相変化が機能しなくなるのではないかと思う. だが,通常の運用において,長時間VRChatにログインすることはないため,無視することにしている.

なお,このブログ中のアセンブリコードはあくまでDirect3D用のアセンブリコードなので,他のプラットフォームでは別のコード生成がされるであろうことは注意したい.

まとめ

  • カスタムレンダーテクスチャは前の状態を取得できるので,ライフゲームの実装にもってこいである
  • 工夫次第でライフゲームの更新部分の条件分岐を無くすことができる
  • 1つのカスタムレンダーテクスチャに処理を詰め込むと実装が苦しくなるので,いくつかのカスタムレンダーテクスチャにすると楽
  • カスタムレンダーテクスチャを利用すれば,アニメーションを用いなくても,ゲーミング的なアレを実現できる.
  • VRChatのアバターに付けたカスタムレンダーテクスチャは機能したりしなかったりするので,重要な部分に用いず,アクセサリ程度にとどめるのがよい

previm + MathJax 3.X

TL;DR

previmでMathJax3.Xを使用できるようにする.

背景

previm/previmMarkdownのプレビューを可能にするプラグインでとてもベンリである. これにMathJaxを組み合わせると,数式が書けるようになり,ものすごくベンリになる.

このMathJaxであるが,2019/9にVer3.0がリリースされていたらしい. 今までVer2.X系を使用していたが,なるべく新しいものを使いたいものである. 実際,MathJax3はMathJax2と比べて処理が高速になったらしい.

しかし,メジャーバージョンアップデートだけあって,2.X系の記述とは異なるものにする必要がある.

現行のWebにあるPrevim + MathJaxの情報はMathJax Ver2.X系のものばかりであるため,Ver3.Xを使用する方法を書くのは意義があると考えた.

変更点

変更点は下記の通りである(git diff の出力結果).

diff --git preview/_/index.html preview/_/index.html
index b08364d..81f142c 100644
--- preview/_/index.html
+++ preview/_/index.html
@@ -38,6 +38,18 @@
             <script src="../_/js/lib/zip_deflate.js"></script>
             <script src="../_/js/lib/plantuml.js"></script>
             <script src="../_/js/previm.js"></script>
+            <script>
+            MathJax = {
+              tex: {
+                inlineMath: [['$', '$'], ['\\(', '\\)']],
+                tags: 'ams'
+              },
+              chtml: {
+                matchFontHeight: false
+              }
+            };
+            </script>
+            <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"></script>
             <div id="monitor"></div>
         </div>
     </body>
diff --git preview/_/js/previm.js preview/_/js/previm.js
index 84f1ae7..4149808 100644
--- preview/_/js/previm.js
+++ preview/_/js/previm.js
@@ -98,6 +98,12 @@
       Array.prototype.forEach.call(_doc.querySelectorAll('pre code'), hljs.highlightBlock);
       autoScroll('body', beforePageYOffset);
       style_header();
+
+      if (typeof MathJax.texReset === 'function') {
+        MathJax.texReset();
+        MathJax.typesetPromise();
+        autoScroll('body');
+      }
     }
   }

tags: 'ams' は数式に番号を付けたくない場合は不要である. tex-chtml.js の読み込みが終わるまで,MathJax.texReset 等のメソッドが生えてこないので,previm.js の関数内では typeof を使用して,メソッドが存在しなければ呼び出しをしないようにしている.

CDNの利用が嫌なら(オフライン環境でもMathJaxを利用したい場合など),ローカルにMathJax一式を落としてきて指定するとよい.

おまけ

このブログは以前までMathJax2を利用していたが,ついでなのでMathJax3を利用するようにした.

参考

C言語でダブルクオートで囲まれたカラムを含むCSVファイルのパースを行う

TL;DR

タイトル通り,C言語でダブルクオートで囲まれたカラムを含むCSVファイル(RFC 4180 2.6章,2.7章参照)のパースを実装した.

背景

2021年にもなってC言語を書かなければならないことがあり,その中でCSVファイルのパースを行う必要があった.

既存の実装を利用しようと,「C言語 CSV」でググってみたのだが,fgets()sscanf() を利用した貧弱なCSVのパースのみであり,実用に耐えないものばかりであった(「1行読み取って」の時点で改行を含むカラムを扱うことができないため,CSVのパースとしては失格である).

そのため,RFC 4180 2.6章,2.7章に従ったCSVを取り扱えるパースを実装した.

本題

とにかく実装は下記の通り. とりあえず動作が確認できるものとして,コマンドライン引数で指定されたCSVファイルをタブ区切りで出力するプログラムとしているが,キモは get_next_csv_token() である. この関数でCSVの1カラムが取得できるので,EOFに到達するまで繰り返し呼び出す.

実装1

#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <string.h>


#if defined(NDEBUG)
#  ifdef _MSC_VER
#    define ASSUME(x)  __assume(x)
#  else
#    define ASSUME(x)
#  endif  // _MSC_VER
#elif defined(assert)
#  define ASSUME(x)  assert(x)
#else
#  define ASSUME(x)
#endif  // defined(NDEBUG)


//! CSVのカラム取得結果の列挙体
typedef enum
{
  //! 通常のカラム
  CSVTOKENTYPE_COLUMN = 0,
  //! 行末のカラム
  CSVTOKENTYPE_EOL_COLUMN = 1,
  //! 既にEOFに到達しており,読み取りを行ったときに返却される値
  CSVTOKENTYPE_NO_COLUMN = 3,
  //! バッファサイズに格納できないときに返却される値
  CSVTOKENTYPE_BUFSIZE_ERROR = -1
} csv_token_type_t;


//! カラムデータの読み取り中断時の状態を格納する構造体
typedef struct
{
  //! バッファサイズ溢れで格納出来なかった文字
  int prev_char;
  //! ダブルクオートで囲まれているかどうか
  bool is_quoted;
  //! 読み取り文字数
  size_t n_read;
} csv_parse_state_t;


static void show_usage(FILE *fp, const char *progname);
static int show_csv_parse_result(const char *filepath);
static csv_token_type_t get_next_csv_token(FILE* fp, char *dst_buf, size_t dst_buf_size, char delim, csv_parse_state_t *state);


/*!
 * @brief このプログラムのエントリポイント
 *
 * @param [in] argc コマンドライン引数の数
 * @param [in] argv コマンドライン引数の配列
 * @return 終了ステータス
 */
int
main(int argc, const char *argv[])
{
  if (argc < 2) {
    show_usage(stderr, argv[0]);
    return 64;  // EX_USAGE
  }

  for (int i = 1; i < argc; i++) {
    printf("==================== CSV File No.%d ====================\n", i);
    show_csv_parse_result(argv[i]);
  }

  return EXIT_SUCCESS;
}


/*!
 * @brief このプログラムの使用方法を表示する
 *
 * @param [in,out] fp 出力先ファイルストリーム
 * @param [in] progname プログラム名
 */
static void
show_usage(FILE *fp, const char *progname)
{
  fprintf(fp, "[Usage]\n");
  fprintf(fp, "  %s [CSV file]...\n", progname);
}


/*!
 * @brief CSVファイルのパース結果を表示する
 *
 * 行番号を付け,1カラムごとにタブ区切りでカラム内容を出力する
 *
 * @param [in] filepath 対象となるCSVファイル
 * @return 正常終了時は0,それ以外は非0
 */
static int
show_csv_parse_result(const char *filepath)
{
  FILE *fp = fopen(filepath, "r");
  if (fp == NULL) {
    perror("fopen");
    return errno;
  }

  size_t colbuf_size = 8192;
  char *colbuf = (char *)malloc(colbuf_size);
  if (colbuf == NULL) {
    fclose(fp);
    perror("malloc");
    return errno;
  }
  char *colbuf2;

  csv_token_type_t ctt;
  csv_parse_state_t state = {0};

  int linenr = 1;
  printf("Line %d: ", linenr);

  while ((ctt = get_next_csv_token(fp, &colbuf[state.n_read], colbuf_size, ',', &state)) != CSVTOKENTYPE_NO_COLUMN) {
    switch (ctt) {
      case CSVTOKENTYPE_COLUMN:
        printf("%s\t", colbuf);
        break;
      case CSVTOKENTYPE_EOL_COLUMN:
        printf("%s\nLine %d: ", colbuf, ++linenr);
        break;
      case CSVTOKENTYPE_NO_COLUMN:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
      case CSVTOKENTYPE_BUFSIZE_ERROR:
        colbuf_size *= 2;
        // オーバーフローのチェックはしない
        colbuf2 = (char *)realloc(colbuf, colbuf_size);
        if (colbuf2 == NULL) {
          free(colbuf);
          fclose(fp);
          perror("realloc");
          return errno;
        }
        colbuf = colbuf2;
        break;
      default:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
    }
  }
  putchar('\n');

  free(colbuf);
  fclose(fp);

  return 0;
}


/*!
 * @brief CSVファイルの次のカラムを得る
 *
 * @param [in,out] fp ファイルストリーム
 * @param [in,out] dst_buf カラムデータ格納先バッファ
 * @param [in]     dst_buf_size カラムデータ格納先バッファのサイズ
 * @param [in]     delim 区切り文字
 * @param [in]     state 読み取り状態
 * @retval CSVTOKENTYPE_COLUMN 通常のカラムの読み取り時
 * @retval CSVTOKENTYPE_EOL_COLUMN 行末のカラムの読み取り時
 * @retval CSVTOKENTYPE_NO_COLUMN fpが既にEOFに到達しているとき
 * @retval CSVTOKENTYPE_BUFSIZE_ERROR バッファに格納できないとき
 */
static csv_token_type_t
get_next_csv_token(FILE* fp, char *dst_buf, size_t dst_buf_size, char delim, csv_parse_state_t *state)
{
  int c = EOF;
  size_t pos = 0;
  csv_token_type_t ctt = CSVTOKENTYPE_EOL_COLUMN;

  // 初回なら読み取り
  if (state->prev_char == '\0') {
    c = fgetc(fp);
    if (c == EOF) {
      return CSVTOKENTYPE_NO_COLUMN;
    }
    if (c == '"') {
      state->is_quoted = true;
    }
  } else {
    // 前回バッファイサイズ溢れで格納できなかった文字を格納
    dst_buf[pos++] = (char)state->prev_char;
  }

  if (state->is_quoted) {
    // ダブルクオート部の読み取り
    while ((c = fgetc(fp)) != EOF) {
      if (c == '"' && (c = fgetc(fp)) != '"') {
        // ダブルクオート囲い終わりの場合
        state->is_quoted = false;
        break;
      }
      // ダブルクオート以外
      // または,ダブルクオートが連続する場合(ダブルクオートのエスケープ)
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  // ダブルクオート外の読み取り
  for (c = (c == EOF ? fgetc(fp) : c); c != EOF; c = fgetc(fp)) {
    if (c == '\n') {
      ctt = CSVTOKENTYPE_EOL_COLUMN;
      break;
    } else if (c == delim) {
      ctt = CSVTOKENTYPE_COLUMN;
      break;
    } else {
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  dst_buf[pos] = '\0';

  state->prev_char = '\0';
  state->is_quoted = false;
  state->n_read = 0;

  return ctt;

buffer_size_error:
  dst_buf[pos] = '\0';
  state->prev_char = c;
  state->n_read += pos;
  return CSVTOKENTYPE_BUFSIZE_ERROR;
}

get_next_csv_token() はバッファサイズの確認も行っており,バッファ溢れが起こる場合,出力先バッファに格納する文字を state に退避して,エラーを返す作りとしている. 呼び出し元で,返り値の確認をし,バッファ溢れがあった場合は格納先バッファを realloc() で2倍の容量で再確保するようにしている.

実装2(簡易実装)

バッファ溢れ時の処理を大雑把にし,もうちょっとシンプルな実装にするなら以下のようにしてもよいと思う. ただし,この実装はバッファ溢れの度にシーク位置を戻して,バッファを拡張後,再度読み直すようにしているので,バッファ溢れが頻繁に起こるならば非効率である. (show_csv_parse_result()get_next_csv_token() 以外は前のものと同じなので省略)

/*!
 * @brief CSVファイルのパース結果を表示する
 *
 * 行番号を付け,1カラムごとにタブ区切りでカラム内容を出力する
 *
 * @param [in] filepath 対象となるCSVファイル
 * @return 正常終了時は0,それ以外は非0
 */
static int
show_csv_parse_result(const char *filepath)
{
  FILE *fp = fopen(filepath, "r");
  if (fp == NULL) {
    perror("fopen");
    return errno;
  }

  size_t colbuf_size = 2;
  char *colbuf = (char *)malloc(colbuf_size);
  if (colbuf == NULL) {
    fclose(fp);
    perror("malloc");
    return errno;
  }
  char *colbuf2;

  csv_token_type_t ctt;

  int linenr = 1;
  printf("Line %d: ", linenr);

  while ((ctt = get_next_csv_token(fp, colbuf, colbuf_size, ',', CSVPARSEERRACT_REWIND_TO_HEAD)) != CSVTOKENTYPE_NO_COLUMN) {
    switch (ctt) {
      case CSVTOKENTYPE_COLUMN:
        printf("%s\t", colbuf);
        break;
      case CSVTOKENTYPE_EOL_COLUMN:
        printf("%s\nLine %d: ", colbuf, ++linenr);
        break;
      case CSVTOKENTYPE_NO_COLUMN:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
      case CSVTOKENTYPE_BUFSIZE_ERROR:
        colbuf_size *= 2;
        // オーバーフローのチェックはしない
        colbuf2 = (char *)realloc(colbuf, colbuf_size);
        if (colbuf2 == NULL) {
          free(colbuf);
          fclose(fp);
          perror("realloc");
          return errno;
        }
        colbuf = colbuf2;
        break;
      default:
        // 絶対にここは通らないがコンパイラの警告抑制のため
        ASSUME(0);
        break;
    }
  }
  putchar('\n');

  free(colbuf);
  fclose(fp);

  return 0;
}


/*!
 * @brief CSVファイルの次のカラムを得る
 *
 * @param [in,out] fp ファイルストリーム
 * @param [in,out] dst_buf カラムデータ格納先バッファ
 * @param [in]     dst_buf_size カラムデータ格納先バッファのサイズ
 * @param [in]     delim 区切り文字
 * @param [in]     erract バッファ溢れエラー時の動作
 * @retval CSVTOKENTYPE_COLUMN 通常のカラムの読み取り時
 * @retval CSVTOKENTYPE_EOL_COLUMN 行末のカラムの読み取り時
 * @retval CSVTOKENTYPE_NO_COLUMN fpが既にEOFに到達しているとき
 * @retval CSVTOKENTYPE_BUFSIZE_ERROR バッファに格納できないとき
 */
static csv_token_type_t
get_next_csv_token(FILE* fp, char *dst_buf, size_t dst_buf_size, char delim, csv_parse_erract_t erract)
{
  int c;
  size_t pos = 0;
  csv_token_type_t ctt = CSVTOKENTYPE_EOL_COLUMN;
  long spos = erract == CSVPARSEERRACT_REWIND_TO_HEAD ? ftell(fp) : 0;

  c = fgetc(fp);
  if (c == EOF) {
    return CSVTOKENTYPE_NO_COLUMN;
  }
  if (c == '"') {
    // ダブルクオート部の読み取り
    while ((c = fgetc(fp)) != EOF) {
      if (c == '"' && (c = fgetc(fp)) != '"') {
        // ダブルクオート囲い終わりの場合
        break;
      }
      // ダブルクオート以外
      // または、ダブルクオートが連続する場合(ダブルクオートのエスケープ)
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  // ダブルクオート外の読み取り
  for (; c != EOF; c = fgetc(fp)) {
    if (c == '\n') {
      break;
    } else if (c == delim) {
      ctt = CSVTOKENTYPE_COLUMN;
      break;
    } else {
      if (pos + 1 >= dst_buf_size) {
        goto buffer_size_error;
      }
      dst_buf[pos++] = (char)c;
    }
  }

  dst_buf[pos] = '\0';
  return ctt;

buffer_size_error:
  if (erract == CSVPARSEERRACT_REWIND_TO_HEAD) {
    fseek(fp, spos, SEEK_SET);
  } else {
    ungetc(c, fp);
  }
  dst_buf[pos] = '\0';
  return CSVTOKENTYPE_BUFSIZE_ERROR;
}

まとめ

C言語RFC 4180の2.6章,2.7章に従ったCSVのパースを実装した. 実運用上ではバッファサイズの制限や,カンマ,改行を含むカラムがあるCSVファイルを扱うことが可能なように最初から考えておいた方がよいと思う.

(本当はちゃんと動作するライブラリを使用するのがよいと思う)

なお,RFC 4180 2.1章ではCSVの改行はCR+LFとなっているが,この記事では従っていない.

参考

clusterに入り浸るようになって半年経過したことについてのポエム

この記事はclusterユーザーと加速する非公式 Advent Calendar 2020の14日目の記事です.

clusterに来るようになったきっかけや,この半年の思い出についてつらつらと書きます.

clusterに入り浸るようになったきっかけ

僕がclusterで本格的に遊ぶようになったのは今年の6月末からです. きっかけは草羽エルさんというVTuberの方がきっかけになります.

Vket4の最終日に草羽エルさんがやっていたワールドツアーに偶然遭遇し,そこで草羽エルさんを知ることができました. そのときにTwitterをフォローしており,clusterでワールドを作り,イベントを開催するというのを知り,覗きにいってそこからclusterにハマりました.

僕は見知らぬ人と話をするのは得意ではなかったのですが,草羽エルさんがBar Angel Kiss,通称「えんきす」で開くイベントに毎回参加する常連の方々とは毎度顔を合わせるので,徐々に(急速に?)仲良くなることができました.

バーチャルなSNSは最初,知り合いがゼロの状態から始まるのですが,僕のようなコミュ障にとっては,ゼロの状態から仲の良い人を作るのは難しいわけです. えんきすがその障壁を突破するきっかけとなり,半年前では信じられないほど仲の良い人達ができました.

そして,毎日のようにclusterに入り,えんきすのイベントに参加したり,深夜遅くまで仲の良い人たち同士で何のこともない雑談を繰り広げるという生活が始まり,良い意味で僕の生活が変わりました(バーチャルな生活が始まりました).

おかげさまで,せっかく購入したはいいものの,割と放置気味になっていたHMDをこの半年で元を取る以上に使うことができたと思います.

えんきすの常連さんの中には,僕と同じようにえんきすがきっかけでバーチャルな生活が始まったという方が数多くいらっしゃるのではないでしょうか?

半年の思い出

今までにclusterで撮った写真のうち,ほんの一部を貼りまくります.

f:id:koturn:20201214030705p:plain f:id:koturn:20201214030710p:plain f:id:koturn:20201214030732p:plain f:id:koturn:20201214030737p:plain f:id:koturn:20201214030749p:plain f:id:koturn:20201214030756p:plain f:id:koturn:20201214030801p:plain f:id:koturn:20201214030809p:plain f:id:koturn:20201214030813p:plain f:id:koturn:20201214030816p:plain f:id:koturn:20201214030822p:plain f:id:koturn:20201214030828p:plain f:id:koturn:20201214030830p:plain f:id:koturn:20201214030834p:plain f:id:koturn:20201214030841p:plain f:id:koturn:20201214030845p:plain

写真を撮るということ

僕はかなり写真を撮影する人間なのですが(VRChatのも含めて25000枚程度!の写真がローカルにあります),それは単純に自分のために思い出に残したいという考えの他に,他の人のためにも写真を残しておきたい,という思いがあったりします.

案外自分を含めて写真を撮るのはちょっと手間がかかりますからね. 後でこういう写真撮れたよ~というのを共有したいわけです.

写真っていいもので,先程のように過去の写真を並べるだけで何かエモくなるものです.

そういうわけでみなさんも写真を撮りまくりましょう. いつか将来に見返したときにめっちゃエモい気持ちになりますよ!

おわりに

今の楽しいバーチャルの生活があるのはひとえにエルルナのお二人,clusterという場,そして仲良くしてくれている皆さんのおかげです. 本当にありがとうございます!

このコロナ禍でリアルで友人と会うことさえ憚られる世間の状況の中,ほぼ毎日の頻度で会う友人が出来たことは僕の人生においてかけがえのない経験・宝物であると思います. (コロナ禍以前のでリアルの友人と会う頻度よりも多いですね,バーチャルの手軽さって素晴らしい)

そして...本日は草羽エルさんの2周年記念日です! 数日前からclusterのイベントページにあるように,草羽エルさんをお祝いするイベントが開催されます. ぜひともお祝いにいきましょう!

おまけ

別のアドベントカレンダーの記事でPNGファイルの無劣化高圧縮についての記事を書きました.

ZopfliというGoogle発のアルゴリズム・ソフトウェアを利用すると,おおよそ20~25%程PNGファイルの容量を削減できることが確認できた,Zopfliいいね~,便利に使うためのラッパーツール書いたよ~って感じのことを書いています.

私のような写真を大量に撮る人間にとっては,20~25%の容量削減はけっこう重要になります. ローカルに写真を大量に保存している人は一度Zopfliで圧縮することを検討してみてもいいかもしれませんね.

また,テクスチャファイルもZopfliで圧縮すれば,VRMのファイルサイズをそれなりに減らすことが出来ることも確認出来たので(自分のアバターのもので25%程度),25MBのファイルサイズ制限に引っかかる人は検討してもいいかもしれません(そんな人いないと思いますがw).

ZopfliPNGによるPNGの可逆再圧縮とVRMの容量削減について

この記事はIQ1 Advent Calendar 2020の7日目の記事です.

TL;DR

zopflipng.dllをいい感じに使うツールを作りました.

  • 並列実行可能
  • zipアーカイブファイル内のファイルを一時的に書き出すことなく処理可能
  • 更新日時タイムスタンプはそのままで再縮率実施

また,最新のバイナリはここに置いてあります.

背景

Zopfli(ツオップリ)とはGoogleが開発したDeflate互換の圧縮アルゴリズムで下記の特徴があります.

  • Deflate互換で高い圧縮率
  • 当然,可逆圧縮
  • 圧縮にはかなりの時間が必要(100倍ぐらいの時間)

そもそも,Deflate圧縮とはzipやgzip形式のファイルに使用されている圧縮形式であり,zopfliを使用すれば単純に高圧縮率のzipファイルやgzipファイルを作成することができるわけですね. つまり,既存のツール(unzipやgunzipなど)でも圧縮結果を伸長することができるのがセールスポイントですね.

そして,PNGファイルに使用されている圧縮方式もDeflateであるため,その部分にもZopfliを適用することができるわけです.

PNGの保存容量を削減するなら,そのPNGファイルのディレクトリを7zip等でLZMA2とかの圧縮アルゴリズムを用いればよくない?と思うかもしれませんが,PNG自体がDeflate圧縮を用いているため,PNGファイル自体に対して何かしらの圧縮アルゴリズムを用いたとしてもほとんど容量の削減が出来ないわけです. なので,PNGファイルの中のDeflate圧縮部分を改善することには大きな意味があります.

GoogleによるZopfliのリファレンス実装のリポジトリでは,gzip形式の圧縮を行なうためのzopfliコマンドおよびPNGの再圧縮を行なうためのzopflipngをビルドすることが可能です.

コマンドだけではなく,.soや.dllといったライブラリファイルとしてビルドし,他の言語から呼び出すことも可能です.

Googleのリファレンス実装のZopfliPNGをデフォルトのオプション指定で利用した場合,20~25%程度の容量削減になることが確認できました. これだけの容量を削減しながら,全く画像の劣化が伴わないのは驚愕ですね.

ただし,1920x1080(FullHDの解像度)のPNG画像だと1枚あたりの処理時間が60~120秒といった具合で,大量の画像を処理するにはなかなかしんどいなといった具合です.

開発動機

何となくZopfliを使用したツールを作ろ~と思ってたら,できあがっていきました(簡単なツールを書いてプログラミング欲を満たしたかっただけ). バイナリはGoogleドライブに置いています.

が必要なはずなので,ご使用の際は上記2つがインストールされていることを確認し,インストールされていなければお使いの環境に合わせたものをインストールしてください.

後付けの動機としては,今年の5月ぐらいからVRChatやclusterといったVR・バーチャルSNSにハマり,写真撮影をしまくるために,PNG画像が大量に貯まってきたので,それをいい感じに圧縮したかったというところです. (ローカルに20000枚ぐらいのPNGファイルがあります...!!)

clusterの写真は撮影後にcluster側に保存され,Webからダウンロードできるのですが,まとめてダウンロードする場合はzipファイルにまとめられています. clusterのPNG画像ファイルはGUID名になっており,タイムスタンプのみが保存日時を知る術となっているので,再圧縮するにしてもタイムスタンプをそのままにしたかったというのと,いちいちディスクに一時ファイルを書き出して再度zipファイルに格納するのは明らかに冗長だと思いました.

また,前述の通り,処理にはものすごく時間がかかるので,1枚の画像の処理に対して1スレッドを割り当てて並列処理を行えば,多少はマシになるかなと思った次第です.

普通はGoogleのリファレンス実装のzopflipngコマンドをそのままシェルスクリプトやバッチファイル等で回せばいいと思うものですが,上記の要件

  • 並列実行可能
  • zipアーカイブファイル内のファイルを一時的に書き出すことなく処理可能
  • 更新日時タイムスタンプはそのままで再縮率実施

を満たすのはしんどいので,とりあえずC# で書きました.

ツールの使用方法

残念ながら(?),私はGUIのツールがあまり好きではないので(自動化しにくいのと自分にとって不要であるのと作るのがめんどくさいので),現状はコマンドラインツールとして開発しています.

基本的に対象がzipファイルの場合,

> ReCompressPng.exe [対象のzipファイル]

ディレクトリが対象の場合,

> ReCompressPng.exe [対象のディレクトリ]

と指定すればOKです. 同時実行スレッド数はデフォルトでは無制限なので,他の作業を並行して行なうために制限したい場合は -n オプションで指定することが可能です. 下記は同時実行スレッド数を2に制限する例です.

> ReCompressPng.exe -n 2 [対象のzipファイル]

オプション表

オプション オプション引数 説明
-c, --count-only 対象zip・ディレクトリ内のPNGファイルとそのサイズ,および合計サイズを表示します.
-h, --help ツールの使用方法を表示し,プログラムを終了します.
-i, --num-iteration 繰り返し回数 Zopfliの繰り返し回数を指定します.
-I, --num-iteration-large 繰り返し回数 大きな画像データに対するZopfliの繰り返し回数を指定します.
大きな画像の閾値は身長後の画像データ長が200000 Bytes以上かどうかです.
-n, --num-thread スレッド数 同時実行スレッド数を指定します.
-r, --replace-force ZopfliPNGによる再圧縮後のPNGファイルのサイズがオリジナルのPNGのサイズより大きくても置きかえを行ないます.
-s, --strategies カンマ区切りの数字 Zopfliのストラテジをカンマ区切りで指定します.例:-s 0,1,2,3,4,6,7 .詳細はZopfliPNGのソースコード等を参照してください.
-v, --verbose カンマ区切りのチャンク名 zopflipng.dllからの標準出力を有効にします.
--keep-chunks 指定したチャンクは除去しないようにします.
--lossy-transparent (※非可逆圧縮)アルファ値0の画素のRGB設定値を0にします.
--lossy-8bit (※非可逆圧縮)16bit画像を8bit画像にします.
--no-overwrite 対象ファイルの上書きを行なわず,別のファイルとして再圧縮結果のPNGファイルを出力します.
--no-auto-filter-strategy ストラテジの自動選択を行なわないようにします.
--no-use-zopfli Zopfliを利用しないようにします.
--no-verify-image 圧縮前後のPNG画像データの比較を行なわないようにします.

注意点

確かにZopfliPNGによる再圧縮では高圧縮率のPNGファイルを得ることができました. しかし,他のペイントツール等で読み込み,そのツールで保存を行なった場合には,通常のPNGのDeflate圧縮が行なわれてしまうため,ファイルサイズが元に戻ってしまいます(可逆圧縮なので当然ですが). ですので,この場合は再度ZopfliPNGに通さなければPNGファイルのサイズが元に戻ったままとなります.

興味本位の調査

VRSNSで使用するアバターは当然テクスチャ画像が含まれているわけですが,このテクスチャ画像のうち,PNGのものをZopfliPNGで再圧縮するとアバターが軽量化できるのではないかと思って,やってみました. アバターの軽量化はネットワーク負荷を軽減させることになるため,他の人のためにもできる限りアバター容量を小さくしたいという思いがあります. (特にclusterでは)

VRM

普段僕がclusterで使用しているアバターは,VRoid Studioで作成し,VRMConverterでUnityに取り込み,UniVRMで出力したものです.

f:id:koturn:20201207193236p:plain

これに対し,UnityプロジェクトのAssetsディレクトリ内のPNGファイル全てをZopfliPNGで圧縮し,全く同じ出力設定で比較を行いました.

結果としては...

f:id:koturn:20201207193541p:plain

なんと,約9MBから6.8MBに容量が削減できました!

VRM形式は全然詳しくはありませんが,結果を見る限りではおそらく,テクスチャ等のPNGファイルをそのまま格納しているのだろうと予想します.

ただ,VRMファイルが軽くなったからといって,ネットワーク負荷が軽くなるかどうかはわからないところです. 例えば,バーチャルSNS:clusterでサーバ側にあるVRMファイルをそのままクライアントに送りつけて,クライアントで展開するという実装になっているのであれば,VRMファイル自体の容量が小さいことに意味はあります. しかし,サーバ側でVRMをクライアントにとって扱いやすい形式に変換し,送りつけているのであれば,画像は一度伸長されていると思われるので,ZopfliPNGによる圧縮はネットワーク負荷の軽減には全く寄与しないわけですね.

とはいえ,VRM自体のファイル容量は削減できているので,アップロードの25MB制限にひっかかったときにZopfliPNGが使えるかもしれないですね(アバターに依りますが25%の容量削減が出来たのはかなり大きいかと!). まぁ,ポリゴン数とかボーン数に引っかかる人がほとんどで,25MB制限に引っかかる人は稀な気がしますが....

VRChat

実験にはBoothで販売されているアバター響狐リクちゃんを使用しました. とても可愛いですね.

これもUnityプロジェクトのAssetsディレクトリ内のPNGファイル全てをZopfliPNGで圧縮してみました. その結果は...

f:id:koturn:20201207192640p:plain f:id:koturn:20201207192644p:plain

なんと容量に変化はありませんでした! 一応,テストビルドのVRChatのアバターファイルを確認したところ,1KB程度の容量の削減は確認できましたが,雀の涙程度でしかないですね....

おそらく画像ファイルは一度展開し,まとめるとか,独自の形式にしているとかやっているのではないかと思われます.

今後の展望

GUIを付ける

一般人ウケを考えると必須な作業ですね. しかしやる気がないので,多分やらないです.

VRMファイル内のPNG画像の再圧縮

VRMファイルの形式には詳しくないですが,PNGの再圧縮によりファイルサイズを減少させることができたので,おそらくPNGファイルはそのままVRMファイル内に格納されているのだと予想できます. となれば,VRMファイル内のPNGファイルを再圧縮し,再度VRMファイルを出力するツールを作ってみたくなりますね.

keep_colortype

Googleの提供しているZopfliPNGのC APIだと,keep_colortype を指定する術がありません

keep_colortype が指定できない場合,現在の実装では keep_colortype = false となるため,例えば,32bit ARGBのPNG画像かつアルファ値が全画素で255(透過しない)となっていると24bit RGB形式に変換してしまうわけです(無駄なアルファチャンネルの削除をするわけですね). この操作では当然視覚的な変化は全く無いし,再度全画素にアルファ値255を加えれば元に戻せるわけですが,厳密には可逆ではない圧縮ということになりますね.

keep_colortype をC++ APIの方からは指定する術はあるのですが,C# 側からこの関数の呼び出しを行うのは,マングルされた関数名を指定してやる必要があるため,しんどいわけです.

となれば,別のDLLにC++ APIを呼び出すだけのC APIの関数を用意するのが,手元で可能なワークアラウンドとなりますが,そのためにDLL 1個を増やすのはなぁ...といったところです.

keep_colortype が追加されたのは 2020-05-24であるため,単にC APIの方への追加忘れではないかと思います. これはプルリクチャンスですかね...誰かやってもらえると助かります.

(配布しているバイナリではzopfli本体のソースコードを修正し,zopflipng.dllを再ビルドした上で,RecompressPng自体も対応するようにしているので,--keep-color-type というオプションを使うことができます.)

余談

ちなみに,zopfliのリポジトリには,gccを使ったビルド方法が簡単に書かれているだけで,MSVCでのビルド方法が書かれていないですが,CMakeLists.txt があるので,それを利用してビルドを行なうのが簡単です.

ただし,普通にビルドしたのでは単純にコマンドラインツールのみが生成されるので,DLLを生成するために, ZOPFLI_BUILD_SHAREDON にする必要があります.

下記はビルド手順の一例です. 最初に,zopfliのリポジトリのトップディレクトリにいるものとします.

> mkdir build
> cd build
> cmake .. -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DZOPFLI_BUILD_SHARED=ON
> cmake --build .

あくまでリファレンス実装であるため,ソースコードがドキュメントだ!と言わんばかりの姿勢がいいですね.

まとめ

とにかくZopfli/ZopfliPNGはすごいよということが言いたかっただけですね. 副産物としてVRMのファイルサイズ削減できることを発見できたのは大きな収穫でした.

みんなもZopfliPNGで色々なPNG画像を圧縮しましょう!

僕の配布しているバイナリが怪しいという意識をお持ちの方は,逆コンパイラ等で中身見て安全であることを確認するなり,GitHubソースコードを落としてきてビルドするなり,自前でプログラム書くなり,オリジナルのZopfliを使うなりするとよいと思います.

一応,Visual Studioやcmakeを用意するのが面倒な人のために,オリジナルのZopfliをビルドしたものも置いてあります

参考

Unityでの変数・プロパティのアクセス性について考える

前置き

最近,Unityに触れており,C#を書くことが多い. 古いUnityの文化はわからないが,publicメンバ変数が気持ち悪くて仕方がない. (おそらく,古代のUnityではプロパティを使用できない,あるいは実行処理系がプロパティのインライン展開を行うことができなかったのではないかと思うが.... また,インスペクタ表示の兼ね合いもあったのかもしれない.)

普通のC#を書いてきた身としては,publicメンバ変数は用いず,代わりに(自動実装)プロパティを用いるべきであるという思いがある. なので,超初歩的な内容ではあるが,メンバ変数宣言とプロパティについてまとめることにした.

変数とプロパティのアクセス表

基本的に使用することがあるのは下記の表のものだと思う. T は適当な型を指す.

プロパティについては自動実装プロパティのみを記載している (public 変数 との対比であるので).

もし,private変数とプロパティを別々に記述する場合は,インスペクタ表示は変数の欄,アクセス性はプロパティの欄に従うことになる.

protectedinternal のような他のアクセス修飾子は些細な違いしかないので省略している. また,setだけのプロパティは普通作らないので,これも省略した.

No. 宣言 自クラスでの値取得 自クラスでの値設定 他クラスから値取得 他クラスから値設定 インスペクタ表示
1 public T foo;
2 public readonly T foo; △(※1) △(※2)
3 private T _foo;
4 private readonly T _foo; △(※1)
5 [SerializeField]
private _foo;
6 public T Foo { get; set; }
7 [field: SerializeField]
public T Foo { get; set; }
8 public T Foo { get; private set; }
9 [field: SerializeField]
public T Foo { get; private set; }
10 public T Foo { get; } △(※1)
11 [field: SerializeField]
public T Foo { get; }
△(※1)

No.1, No.2はpulbicメンバ変数なので使用すべきでない,No.11のgetオンリーなプロパティはたとえ SerializeField 属性を付与しようとも,バッキングフィールドが readonly であるため,インスペクタからは見えず,SerializeField の意味がないという点で使用すべきでない. なので,No.3~No.10の8つのうち,いずれかを使用すべきである.

※1:初期化時のみ値設定が可能. 例えば,コンストラクタ内や,

public class Foo
{
    private readonly int _hoge;

    public int Fuga { get; }

    Foo(int hoge, int fuga)
    {
        // これはOK
        _hoge = hoge;
        Fuga = fuga
    }

    // SetAndShow(int hoge, int fuga)
    // {
    //     // これはNG
    //     _hoge = hoge;
    //     Fuga = fuga;
    // }
}

宣言と同時に初期化では可能.

public class Foo
{
    private readonly int _hoge = 42;

    public int Fuga { get; } = 84;
}

MonoBehaviour 継承クラスにコンストラクタを実装することはないので,Unityではあまりreadonlyなメンバ変数とgetオンリーな自動実装プロパティを利用する機会は少ないかもしれない.

※2: 値型はunsafeな手段を用いれば書き換え可能.

class Program
{
    static void Main(string[] args)
    {
        var f = new Foo();
        Console.WriteLine(f.hoge);  // => 42
        unsafe
        {
            fixed (int* p = &f.hoge)
            {
                *p = 20;
            }
        }
        Console.WriteLine(f.hoge);  // => 20
    }
}

public class Foo
{
    public readonly int hoge = 42;
}

自動実装プロパティのインスペクタでの表示

自動実装プロパティの場合,バッキングフィールド名がそのままインスペクタに表示されてしまう. それが嫌なら,

ここにある RenameFieldAttribute のようなものを用いて,インスペクタの表示を操作してやるか,

[field: SerializeField]
[field: RenameField("Foo")]
public int Foo { get; set; }

自動実装プロパティをやめ,実態となる [SerializeField] private変数を別に用意し,それに対するプロパティを用意する必要がある.

/// <summary>
/// インスペクタからは見えるが,他クラスからは見えない
/// </summary>
[SerializeField]
private int _foo;

/// <summary>
/// 他クラスから<see cref="_foo"/>を見るためのプロパティ
/// </summary>
public int Foo
{
    get { return _foo; }
    set { _foo = value; }
}

UdonSharpについて

なお,現在のUdonSharpでは自作クラスにプロパティを使用することはできないし,そもそもpublicメンバ変数であっても普通にアクセスすることもできない. なので,おそらく裏でリフレクションを使用しているであろう手段でアクセスすることになるが,これはアクセス修飾子を無視してアクセスできるので,おとなしくpublicメンバ変数を用いるのがよいと思われる(そのうち普通にアクセスできるようになると思うし,変数publicだと他クラスでも nameof() で変数名を取得できるため,typoを実行時ではなくコンパイル時に検知できる).

まとめ

基本的にはprivate変数かpublicプロパティのどちらかの選択となり,あとはインスペクタ表示が必要かどうかで判断することになる.

  1. 外部クラスから見える必要があるか?
    • Yes: プロパティ
    • No: private変数
  2. インスペクタから操作できる必要があるか?(Unityに限る)
    • Yes: [SerializeField] を付ける
    • No: そのまま
  3. 値設定は初期化時のみに限定したい?
    • Yes: readonlyな変数 / getオンリーなプロパティ
    • No: そのまま

参考

C#のシングルトン

背景

最近,UnityでC#を書くので,普通のC#を書くときに使っていたコードスニペットを整理していた. その中にシングルトンの実装が含まれているのを見て,そういえばこの実装はスレッドセーフなのか?と疑問に思ったのがきっかけで,C#のシングルトンについて調べ直した. この記事では備忘録としてシングルトンの各種実装をまとめた.

4つのシングルトン実装

実装その1(非スレッドセーフ)

単純な実装は下記の通り.

初回に Instance プロパティを通じてインスタンスの取得を試みたとき,インスタンスの作成と保持を行い,保持したインスタンスを返す. 2回目以降は保持したインスタンスを返す. いわゆる遅延初期化という実装である.

当然だがスレッドセーフではない. また,些細なものだが,アクセスの度にnullチェックが入るのが気になる.

public sealed class Singleton
{
    private static Singleton _instance;

    public static Singleton Instance => _instance ??= new Singleton();

    public string Name { get; }

    private Singleton()
    {
        Name = nameof(Singleton);
    }
}

実装その2(スレッドセーフっぽいけど保証はされないらしい)

なればとクラス自体の初期化が行われた際にインスタンスを作成する下記の実装を考える.

一見スレッドセーフっぽいが,実はスレッドセーフであることは保証されないとのこと.

静的コンストラクタが定義されていないクラスには,beforefieldinit属性が付加される. そして,beforefieldinit属性が付与されていると,

  1. 確実に一回だけ、type initializer が呼ばれる
  2. type initializer が完了するまで、他のスレッドが静的フィールドにアクセスしたとしても待たされる

という事項がCLI仕様として保証されないらしい

また,初期化タイミングがその型の静的フィールドへの初回アクセス時になるとは限らず,それ以前になることもあるかもしれないらしい.

public sealed class Singleton
{
    public static Singleton Instance { get; } = new Singleton();

    public string Name { get; }

    private Singleton()
    {
        Name = nameof(Singleton);
    }
}

実装その3(スレッドセーフ(コンパイラ依存らしいが))

というわけで,静的コンストラクタを定義すればよい. 中身は空でもよい.

実際,その2の生成コードとその3の生成コードを比較すると,前者にはbeforefieldinit属性が付与されており,後者には付与されていないことが確認できた.

ただし,これはコンパイラ依存であるらしく,コンパイラ非依存のシングルトンにするためには,別の手法が必要になる.

個人的にはこの実装でよいと思うが....

public sealed class Singleton
{
    public static Singleton Instance { get; } = new Singleton();

    static Sigleton()
    {
        // beforefieldinit属性を付与されないための静的コンストラクタ
        // 空だが消去してはならない
    }

    public string Name { get; }

    private Singleton()
    {
        Name = nameof(Singleton);
    }
}

下記のような静的コンストラクタで初期値代入を行うようにしてもよいと思う.

public sealed class Singleton
{
    public static Singleton Instance { get; }

    static Sigleton()
    {
        Instance = new Singleton();
    }

    public string Name { get; }

    private Singleton()
    {
        Name = nameof(Singleton);
    }
}

実装その4(スレッドセーフ(コンパイラ非依存))

最初の遅延初期化の案にロックを使用するようにすれば,確実にスレッドセーフなシングルトン実装になる. いわゆる,Double Checked Lockingである.

ロックはそのスレッドから見て,シングルトンインスタンスが生成されていない場合にのみ行うので,アクセスの度にロック取得を行なうわけではない.

ただ,getプロパティのバイトコードサイズが手元のコンパイル結果だと83 Bytesになっており,lockも含むので,実行時にインライン展開されるかどうかはかなりあやしい(未確認).

public sealed class Singleton
{
    private static Singleton _instance;

    private static readonly object _syncRoot = new object();

    public static Singleton Instance
    {
        get
        {
            if (_instance == null)
            {
                lock (_syncRoot)
                {
                    if (_instance == null)
                    {
                        _instance = new Singleton();
                    }
                }
            }
            return _instance;
        }
    }

    public string Name { get; }

    private Singleton()
    {
        Name = nameof(Singleton);
    }
}

ジェネリックなシングルトン実装

複数のシングルトンクラスの実装を行うと,似たようなコードを書いて実装という形になる. ここを何とかしたいと人間考えるものである.

なので,ジェネリックなシングルトンクラスを考える. ベースとしては前述の実装その3を採用する.

先に結論を書くと,一長一短であり,理想的な実装はない. 個別にシングルトンクラスを定義するのが一番よい.

実装その1(コンストラクタをpublicにせざるを得ない)

C++でいうところのCRTPに似た形でジェネリックなシングルトンクラスの実装を行う. C++のフレンドクラスのような機能はC#にはないため,シングルトンにするクラスのコンストラクタを public にしなければ,ジェネリッククラス側でコンストラクタが不可視のため,コンパイルできない.

public abstract class Singleton<T>
    where T : class, new()
{
    public static T Instance { get; }

    static Singleton()
    {
        Instance = new T();
    }
}

public sealed class Foo : Singleton<Foo>
{
    public string Name { get; }

    public Foo()
    {
        Name = nameof(Foo);
    }
}

実装その2(リフレクションを利用)

リフレクションを利用すれば,privateなコンストラクタへアクセスできる.

しかし,

  1. インスタンスの生成のコストが高い.
  2. 引数有りコンストラクタを定義すると,デフォルトコンストラクタが定義されなくなり,実行時エラーになる.コンパイルエラーにはできない.

という問題がある.

public abstract class Singleton<T>
    where T : class
{
    public static T Instance { get; }

    static Singleton()
    {
        Instance = (T)Activator.CreateInstance(typeof(T), true);
    }
}

public sealed class Foo : Singleton<Foo>
{
    public string Name { get; }

    private Foo()
    {
        Name = nameof(Foo);
    }
}

まとめ

様々なシングルトンクラスの実装を紹介した.

静的コンストラクタの有無によって,beforefieldinit属性の有無も変化し,それによってCLIの仕様として,スレッドセーフな初期化が保証されるかどうかが変わるため,遅延初期化でない実装では,空でも静的コンストラクタを定義した方がよい.

また,完璧なジェネリックシングルトンクラスの実装はできない.

参考