壁がぶっこわれるエフェクトの作り方

[MoAR の Watching](https://whatever.co/moar/ja/watching/) という作品ではビルの壁がリアルタイムに破壊されるという表現があります。
[<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/b7aba44f-052c-45d2-2203-7768bc6d2f95.gif" width="100%">](https://www.youtube.com/watch?v=b3h5jwMB1B4)

[emoc](https://whatever.co/moar/ja/emoc/) という作品でも同様の表現が使われています。
[<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/568ea92a-85cb-eaba-6bf4-9923c26fae64.gif" width="100%">](https://www.youtube.com/watch?v=co_wazLSOlM)

これらの破片オブジェクトは事前にビルのテクスチャが貼られているわけではありません。

ビルのテクスチャの明るさや解像度は各デバイスのカメラ性能によってまちまちだし、AR 体験時の時刻や天候によっても様々に変化するので事前に貼り付ける方法ではかなり違和感が残ります。

このエフェクトをを違和感なく実現するために、AR体験中のライブカメラ映像から、各オブジェクト（メッシュ）が必要とするテクスチャの UV 値をリアルタイムに計算し、毎フレーム貼り付けています。

# どういうこと？

1. あらかじめ破片を作っとく。
1. 壊す前の破片の各頂点のスクリーン座標を計算する。
1. スクリーン座標から UV 値を計算する。
1. 破片を動かす。

![Untitled-1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/b42ed673-b497-897f-071d-0212b896942d.jpeg)


## 1. あらかじめ破片を作っとく

Blender で [Cell Fracture Add-on](https://docs.blender.org/manual/en/latest/addons/object/cell_fracture.html) 使って適当に破片をつくる。
![fractures](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/61ec7ee5-3e66-6927-8dce-f42ac8c4c38e.png)

## 2. 壊す前の破片の各頂点のスクリーン座標を計算する

**ARKit (Geospatial API) が正確にビルをトラッキングしている状態で**、破片がまだ壁を形成している壊す前の状態のメッシュの各頂点のスクリーン座標を計算する。これは通常やってることなので簡単。

## 3. スクリーン座標から UV 値を計算する

これは簡単でしょーと思いきや… [`ARFrame.capturedImage`](https://developer.apple.com/documentation/arkit/arframe/2867984-capturedimage) のサイズが必ずしも画面サイズと同一ではないので（iPhone 13 Pro の場合 1920x1440px）画面外にはみ出た部分を考慮しないといけないし、さらにカメラセンサーの都合上横向きになってるのでそこも含めて計算しないといけないのがちょい面倒ポイント。
 ![apple-iphone-13-pro-2021-medium.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/372a8ddb-cfb5-b28c-6654-f115aff274a4.jpeg)

これで各メッシュの壁テクスチャの設定が完了したので、あとは破片を動かすだけ。

ただしビルのカメラ画像が更新されるとトラッキング中のメッシュのスクリーン座標も変わってしまうので、この UV 値の処理は毎フレーム実行する必要がある。

## 4. 破片を動かす

これまでの手順で適切にリアルタイムに壁テクスチャが貼れているのであとは [SceneKit の Physics Simulation](https://developer.apple.com/documentation/scenekit/physics_simulation) などを使って適当に動かすだけ。

# Metal シェーダーでやる

UV 値計算を全部 Metal シェーダーでやっちゃう。

```cpp
#include <metal_stdlib>
using namespace metal;

#include <SceneKit/scn_metal>

struct NodeBuffer {
    // これはいつもの MVP 行列。壊れて飛んでく位置回転。
    float4x4 modelViewProjectionTransform;
};

struct Uniforms {
    // カメラ画像サイズ補正用
    float2 capturedImageScale;
    // UV 値計算用の初期姿勢の MVP 行列 
    float4x4 modelViewProjectionTransform;
};

struct VertexInput {
    float3 position  [[attribute(SCNVertexSemanticPosition)]];
    float2 uv [[attribute(SCNVertexSemanticTexcoord0)]];
};

struct VertexOut {
    float4 position [[position]];
    float4 uv;
};

vertex VertexOut liveCamTextureVertex(VertexInput in [[ stage_in ]],
                                      constant NodeBuffer& scn_node [[buffer(1)]],
                                      constant Uniforms &uniforms [[buffer(2)]]) {
    VertexOut out;
    out.position = scn_node.modelViewProjectionTransform * float4(in.position, 1.0);
    // uv につっこんでるけど UV 値ではなくって初期位置のプロジェクション座標
    out.uv = uniforms.modelViewProjectionTransform * float4(in.position, 1.0);
    return out;
}

fragment float4 liveCamTextureFragment(VertexOut out [[ stage_in ]],
                                       texture2d<float, access::sample> capturedImageTextureY [[texture(0)]],
                                       texture2d<float, access::sample> capturedImageTextureCbCr [[texture(1)]],
                                       constant Uniforms &uniforms [[buffer(2)]]) {

    // スクリーン座標に変換して画面はみ出てる部分の補正
    float2 uv = ((out.uv.xy / out.uv.w) * uniforms.capturedImageScale + 1.0) * 0.5;
    // 縦横方向かえる
    uv = float2(1.0 - uv.y, 1.0 - uv.x);

    constexpr sampler colorSampler(mip_filter::linear,
                                   mag_filter::linear,
                                   min_filter::linear);
    
    // YCbCr から RGB への変換行列
    const float4x4 ycbcrToRGBTransform = float4x4(
        float4(+1.0000f, +1.0000f, +1.0000f, +0.0000f),
        float4(+0.0000f, -0.3441f, +1.7720f, +0.0000f),
        float4(+1.4020f, -0.7141f, +0.0000f, +0.0000f),
        float4(-0.7010f, +0.5291f, -0.8860f, +1.0000f)
    );

    float4 ycbcr = float4(capturedImageTextureY.sample(colorSampler, uv).r,
                          capturedImageTextureCbCr.sample(colorSampler, uv).rg, 1.0);
    
    // YCbCr から RGB への変換
    float4 c = ycbcrToRGBTransform * ycbcr;
    // Linear から sRGB へ変換
    c.rgb = pow(c.rgb, 2.2);
    return c;
}
```

## カメラ画像のフォーマット

上のシェーダー内でもやってるけど [`ARFrame.capturedImage`](https://developer.apple.com/documentation/arkit/arframe/2867984-capturedimage) は RGB 画像ではなくって YCbCr フォーマットになってるので変換してやる必要がある。といっても[サンプルコード](https://developer.apple.com/documentation/arkit/displaying_an_ar_experience_with_metal)が提供されてるので capturedImage の CVPixelBuffer から YCbCr テクスチャつくってシェーダーで RGB に変換するまではほぼコピペでいける。

# バグ？

![ob.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/465a43f9-04ce-49e6-930d-c16b8e510410.gif)
ビルが画面外にでちゃうと [texture wrapping mode](https://developer.apple.com/documentation/scenekit/scnwrapmode) が `repeat` になってるせいでビルじゃないところの色をピックアップしちゃう。`repeat` じゃなくて `clampToBorder` にして適当な色を `borderColor` に設定するのがよかったかなー？ それかちゃんとテクスチャが設定できたときのカメラ画像と UV 値を保存しておいてビルが画面外にでたときはそれを使ってごまかすとかすればよいかもー。