koturnの日記

普通の人です.ブログ上のコードはコピペ自由です.

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

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のアバターに付けたカスタムレンダーテクスチャは機能したりしなかったりするので,重要な部分に用いず,アクセサリ程度にとどめるのがよい