AR作品「シャボン da さいたま」技術解説

さいたま国際芸術祭2023にて、STYLYを使ったAR作品を出展しました。
本稿はその作品の技術解説になります。

# さいたま国際芸術祭とは

2016年、2020年とさいたま市で開催されてきた芸術祭の3回目の開催。
今年は現代アートチーム 目 [mé]がディレクターでした。。
メイン会場は旧市民会館おおみやだけど、さいたま市内のあちこちで公募プログラムが催されています。
テリー・ライリーやジム・オルークがコンサートやったりしてた。（両方観れてハッピー♪）
[さいたま国際芸術祭 公式サイト](https://artsaitama.jp/)

# シャボン da さいたま ～レンズの向こうのわたしとワタシ～

市民公募プロジェクトの１つで、浦和にある写真スタジオ「STUDIO・45」にて催されたインスタレーションです。
映像で表現された仮想のシャボン玉に自分を重ね、それをカメラで写したものを会場内に展示していく。
という展示がメインで、その他にも複数の作品を展示しました。
ひょんなきっかけから私もその一つとしてAR作品を展示させて頂きました。

[シャボン da さいたま ～レンズの向こうのわたしとワタシ～](https://artsaitama.jp/events/mkd5jw9_1/)

| ![IMG_6472.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5671/69ad9d9d-7565-e9da-e5e8-d647e86f7c25.jpeg) |
|:-:|
| 会場はこんな感じでした。 |

# AR作品「シャボン da さいたま」

*人はみんなそれぞれ違う* 
*そんなこと言われなくたって分かってる*
*でも外から見える違い以上に*
*世界の見え方はそれぞれ違うのかもしれない*

| ![ステッカー入稿データ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5671/24fe34b6-3bf4-fabe-22b4-3f7728dc8a50.png) |
|:-:|
| スマホでこのQRを読めば体験できます |

画面をタップすると前方に大きなシャボン玉が現れて、その中に入り込むと内側からは世界が異なった見え方になる、というSTYLYを使ったAR作品です。
展示会場以外の場所でも楽しめるように作ったので、公園や街の中など色んな場所で試してみてください。
「外見や属性の違い以上に、その人の世界の見え方・捉え方というものはより多種多様なんじゃないだろうか」ということを表現したくて作ってみました。

実際にどんな感じなのか動画を撮ってみました。

https://youtube.com/shorts/RrZojH9mW6o

# シャボン玉の構造

![スクリーンショット 2023-12-15 18.02.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5671/0e9782e2-e43f-dc3e-96f8-66e2563b9102.png)

シャボン玉のベースパターンは全部で10種類あります。
1つパターンごとに異なるprefabになっていますが同じ構造を持っています。
外から見た際のメッシュと、シャボン玉の内側から外を見た際のメッシュの２つで構成されます。

前述したように「外見の違い以上に、その人の内側から見た世界は多種多様」みたいな話を表現したかったので、外見はだいたい同じでいいけど内側からは全く異なる見え方が大量にある、というリソース配分で開発をしました。

## 外見用のシェーダー

外見に関しては実際のシャボン玉っぽいのがいいな、と思ってこちらの記事を参考にさせてもらいました。

https://edom18.hateblo.jp/entry/2020/01/24/101302

構造色レンダリングの話を何とか理解しようとしたんですが、なかなか難しいですね。
結局何となくパラメータをいじったりしながら色を少し変えたりした程度で、全てのシャボン玉は1シェーダー共通になっています。
試しにこのshaderをiOS/Androidで表示してみましたが、何故かiOSだけ黒くなってしまう箇所が発生しました。

| ![IMG_6323.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5671/b4d8363a-af50-0a6d-cd31-3e4859528c6e.png) | ![IMG_6321.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5671/6bd1b5fa-77e3-bdc3-c6d8-e8bc231bbfb1.png) |
|:-:|:-:|
| Android | iOS |

恐らくどこかの計算上で値が反転してるか、意図しない範囲に飛んでしまっているかだと思います。
CubemapやRimLightの処理をカットしてみたりしたもののよく分からず、結局は最後にspecを足し込むところを除外して強引に解決しました。

```hlsl
// 何故かiOSでは黒い領域が出てしまうので処理を除外
#if !SHADER_API_METAL
                col += spec;
#endif
```

<details><summary>修正したshader全体</summary>

```hlsl
Shader "Unlit/bubble_look"
{
    Properties
    {
        [PowerSlider(0.1)] _F0("F0", Range(0, 1)) = 0.02
        _RimLightIntensity ("RimLight Intensity", Float) = 1.0
        _Color ("Color", Color) = (1, 1, 1, 1)
        _MainTex ("Texture", 2D) = "white" {}
        _DTex ("D Texture", 2D) = "gray" {}
        _LETex ("LE Texture", 2D) = "gray" {}
        _CubeMap ("Cube Map", Cube) = "white" {}
    }

    SubShader
    {
        Tags
        {
            "RenderType"="Transparent"
            "Queue"="Transparent"
        }
        LOD 100

        Pass
        {
            Cull Back
            Blend SrcAlpha OneMinusSrcAlpha

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
                float3 tangent : TANGENT;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
                float3 normal : TEXCOORD1;
                float3 tangent : TEXCOORD2;
                float3 viewDir : TEXCOORD3;
                float3 lightDir : TEXCOORD4;
                half fresnel : TEXCOORD5;
                half3 reflDir : TEXCOORD6;
            };

            sampler2D _MainTex;
            sampler2D _DTex;
            sampler2D _LETex;

            UNITY_DECLARE_TEXCUBE(_CubeMap);

            float _F0;
            float _RimLightIntensity;
            float4 _MainTex_ST;
            fixed4 _Color;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                o.normal = v.normal;
                o.tangent = v.tangent;
                o.viewDir  = normalize(ObjSpaceViewDir(v.vertex));
                o.lightDir = normalize(ObjSpaceLightDir(v.vertex));
                o.fresnel = _F0 + (1.0h - _F0) * pow(1.0h - dot(o.viewDir, v.normal.xyz), 5.0);
                o.reflDir = mul(unity_ObjectToWorld, reflect(-o.viewDir, v.normal.xyz));
                return o;
            }

            fixed2 random(fixed2 st)
            {
                st = fixed2(
                    dot(st, fixed2(127.1, 311.7)),
                    dot(st, fixed2(269.5, 183.3)));

                return -1.0 + 2.0 * frac(sin(st) * 43758.5453123);
            }

            float perlinNoise(fixed2 st)
            {

                fixed2 p = floor(st);
                fixed2 f = frac(st);
                fixed2 u = f * f * (3.0 - 2.0 * f);

                float v00 = random(p + fixed2(0, 0));
                float v10 = random(p + fixed2(1, 0));
                float v01 = random(p + fixed2(0, 1));
                float v11 = random(p + fixed2(1, 1));

                float a = lerp(dot(v00, f - fixed2(0, 0)), dot(v10, f - fixed2(1, 0)), u.x);
                float b = lerp(dot(v01, f - fixed2(0, 1)), dot(v11, f - fixed2(1, 1)), u.x);
                return lerp(a, b, u.y) + 0.5;
            }
            
            fixed4 frag(v2f i) : SV_Target
            {
                i.uv = pow((i.uv * 2.0) - 1.0, 2.0);
                float d = perlinNoise((i.uv + _Time.xy * 0.1) * 3.0);

                float u, v;

                // Calculate U.
                {
                    float ln = dot(i.lightDir, i.normal);
                    ln = (ln * 0.5) * d;

                    float lt = dot(i.lightDir, i.tangent);
                    lt = ((lt + 1.0) * 0.5) * d;

                    u = tex2D(_LETex, float2(ln, lt)).x;
                }

                // Calculate V.
                {
                    float en = dot(i.viewDir, i.normal);
                    en = ((1.0 - en) * 0.5 + 0.5) * d;

                    float et = dot(i.viewDir, i.tangent);
                    et = ((et + 1.0) * 0.5) * d;

                    v = tex2D(_LETex, float2(en, et)).x;
                }

                float2 uv = float2(u, v);
                float4 col = tex2D(_MainTex, uv);

                float NdotL = dot(i.normal, i.lightDir);
                float3 localRefDir = -i.lightDir + (2.0 * i.normal * NdotL);
                float spec = pow(max(0, dot(i.viewDir, localRefDir)), 10.0);

                float rimlight = 1.0 - dot(i.normal, i.viewDir);

                fixed4 cubemap = UNITY_SAMPLE_TEXCUBE(_CubeMap, i.reflDir);
                cubemap.a = i.fresnel;

                col *= cubemap;
                col += rimlight * _RimLightIntensity;
                // 何故かiOSでは黒い領域が出てしまうので処理を除外
#if !SHADER_API_METAL
                col += spec;
#endif
                return col;
            }
            ENDCG
        }
    }
}
```

</details>

## 内側から外を見た時用のシェーダー

内側から見た時のバリーションはとにかくたくさん欲しかったので、全て個別のシェーダーになっています。
更に全てのシェーダーには0〜1の範囲の乱数を2つ渡すようになっていて、シェーダー内で分岐させたり大きく見た目が変わるようなパラメータにかけあわせたりして、パターンをかさ増ししています。
カメラ入力に対してエフェクトをかけるこのシェーダーが、本作の技術的に一番メインの部分になっています。
ではこれをどのように作っているかを説明していきます。

# Grabpass

GrabPassとはUnityのShaderLabの機能の一つで、レンダリング処理の途中の状態のレンダリング内容をshader内でテクスチャとして掴むことができる、という機能です。
これを使ってカメラ入力画像が画面にレンダリングされたものをテクスチャとして参照できるので、そこに対してあれこれ操作を行います。
詳しい説明は割愛しますが公式マニュアルと、下記の記事が参考になると思います。
[Grabpassの公式マニュアル](https://docs.unity3d.com/ja/2019.4/Manual/SL-GrabPass.html)
参考記事：[GrabPassの説明とシェーダの実装例](https://light11.hatenadiary.com/entry/2018/12/12/000345)

サンプルとして、単純にカメラ入力をポジネガ反転するだけのシェーダーを載せておきます。

<details><summary>Grabpassのサンプルshader</summary>

```hlsl
Shader "Unlit/GrabPassTest"
{
    Properties
    {
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" }
        GrabPass { "_GrabPassTexture" }

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            sampler2D _GrabPassTexture;
            
            struct v2f
            {
                float4 vertex : SV_POSITION;
                float4 grabPos : TEXCOORD0;
            };

            v2f vert (float4 vertex : POSITION)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(vertex);
                o.grabPos = ComputeGrabScreenPos(o.vertex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2Dproj(_GrabPassTexture, i.grabPos);
                // 単純なネガポジ反転
                col.rgb = 1 - col.rgb;
                return col;
            }
            ENDCG
        }
    }
}

```

</details>

ちなみに余談ですが、芸術祭[MIND TRAIL 奥大和 心のなかの美術館](https://mindtrail.okuyamato.jp/)のゴッドスコーピオン作品[孔雀の呪法（AR）](https://mindtrail.okuyamato.jp/2023/wp/exhibit/shimokitayama_god_scorpion_01/)にも技術サポートとして関わっていて、この作品でもGrabpassを使ったシェーダー表現を作りました。

# Grabpassではできない表現

ほとんどのパターンはGrabpassを使って作っていますが、幾つかGrabpassでは実現できない凝った表現があります。
例えばこういうものです。

https://www.youtube.com/shorts/gQJVXcYsfrM

ちょっと話は脱線しますが、私はよく[hydra](https://hydra.ojack.xyz/)を使ってVJをしたりビジュアルを作って遊んだりしています。
hydraそのものの詳細な説明は割愛しますが、hydraは1フレーム前に書いた絵を参照して何かするのを短いコードで書きやすいんですよね。
わりとそういうフィードバックループを使った表現が好きで多用しています。
hydraだと下記のようなコードになります。

```js
s0.initCam(0)
src(s0)
.diff(src(o0).scale(.95).rotate(.05))
.out(o0)
```

[hydra上での上記コードの実行](https://hydra.ojack.xyz/?code=czAuaW5pdENhbSgwKSUyMCUyRiUyRiUyMCVFMyU4MSU5MyVFMyU4MSVBRSVFMyU4MiVBQiVFMyU4MyVBMSVFMyU4MyVBOSVFMyU4MyU4NyVFMyU4MyU5MCVFMyU4MiVBNCVFMyU4MiVCOSVFNyU5NSVBQSVFNSU4RiVCNyVFMyU4MSVBRiVFNyU5MiVCMCVFNSVBMiU4MyVFMyU4MSVBQiVFMyU4MiU4OCVFMyU4MSVBMyVFMyU4MSVBNiVFNiU5QiVCOCVFMyU4MSU4RCVFNiU4RiU5QiVFMyU4MSU4OCVFMyU4MSVBNiVFMyU4MSU4RiVFMyU4MSVBMCVFMyU4MSU5NSVFMyU4MSU4NCUwQSUwQXNyYyhzMCklMEEuZGlmZihzcmMobzApLnNjYWxlKC45NSkucm90YXRlKC4wNSkpJTBBLm91dChvMCk)

で、こういう表現をやるためには1フレーム前のレンダリング結果を得る必要があります。
[カスタムレンダーテクスチャ](https://docs.unity3d.com/ja/2019.4/Manual/class-CustomRenderTexture.html)を使えば簡単に実装できるのですが、あいにくこれはSTYLYのmobileアプリ動作対象としているiOS/Androidデバイス上では機能しません。
そのため自分でうまくフィードバックループ機構を作る必要があります。

# GetARCameraTextureカスタムアクション

mobile版STYLY v9.4.20 及び [STYLY Plugin for Unity](https://styly.cc/ja/download/) v1.8.0で、PlaymakerのカスタムアクションとしてGetARCameraTextureが追加されています。
これを使うとカメラ画像を指定のRenderTextureに書き込ませることができます。
実はちょっと古いバージョンでもカメラ画像を取得する機能はあったのですが、色々とバグがあったりして非公開になった経緯があります。
GetARCameraTextureは色々な問題をクリアした代替の機能として新たに作成したものです。
このカスタムアクションの詳しい説明については近日中に[STYLY Magazine](https://styly.cc/ja/all/)上に記事を作成予定です。
ただ使い方は難しくないので、Playmakerを使える人は後述のサンプルを参照して試してみてください。

| ![スクリーンショット 2023-12-17 1.59.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5671/55198ee3-91e3-d907-631d-2b96306e849d.png) |
|:-:|
| RenderTextureを渡して呼ぶだけ |

# フィードバック処理の実装

では取得できたカメラ画像のRenderTextureを使ってフィードバックループ機構を作ります。
これは文字で説明するより見てもらった方が早いと思うのでサンプルを用意しました。
[サンプルのunitypackage](https://drive.google.com/file/d/1kiOs2QQbA8y1Q3ZWzqfyAuWtA-rzs2J3/view?usp=sharing)
[サンプルシーン](https://gallery.styly.cc/scene/83f6b9f9-e376-4cd2-bb36-7a7c1598e2ca)(mobileアプリで見てください)

ざっくりとだけ説明します。
まずカメラ画像のRenderTexture(**A**)を適当なQuadに張り付けます。
そしてそのQuadをちょうど映すOrthographicなCameraを置きます。
このQuadとCameraはY:-100とか、適当に遠い場所に置いちゃいましょう。
CameraのRenderTargetを別途用意したRenderTexture(**B**)にします。
Quadに適用するmaterial(のシェーダー)が**A**と**B**を両方パラメータとして受けるようにすれば、カメラ画像と1フレーム前のレンダリング結果とを参照できます。
こうなれば後はシェーダー側でそれらを使って好きにエフェクトを書けますね。
で、OverlayなCanvas上に画面いっぱいに引き伸ばしたRawImageを置いて、そいつに**B**を適用することで、めでたく端末の画面にレンダリングされるという寸法です。

| ![スクリーンショット 2023-12-17 2.23.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/5671/416ed689-e6ad-59bb-cc99-4003567a9cc3.png) |
|:-:|
| **こういうことです！（伝われ）** |

# 総括

今回はあまり制作時間を取れない状況で参加することになったので、シェーダー自体は自分が手癖で書ける程度の比較的シンプルなものに抑えました。
しかしまぁトータルでは個人的に満足できる作品になったかなと思います。
それ以外にも、開発を効率化できるような工夫や考え方などを盛り込んだことで、短い期間の中でわりと複雑なものをバグなく作り込めたことにも満足しています。
そういった開発の工夫みたいな話もまたいずれかの機会にしたいですね。

# 宣伝
PsychicVRLabではUnityエンジニア・サーバーサイドエンジニアを募集しています！！
ご応募お待ちしています！！

https://www.wantedly.com/projects/1360135

https://www.wantedly.com/projects/1360123