おばけが窓から窓へ移動しながら掃除機に吸い込まれる動き

[MoAR](https://whatever.co/moar/) のコンテンツのひとつ、"[Ghost Building](https://whatever.co/moar/ja/ghost_building/)" に出てくるおばけのモーションを作ったときのお話。

# おばけが窓から窓へ移動する

<iframe width="100%" height="600" src="https://www.youtube.com/embed/qGhVFazVsPQ?rel=0" title="Ghost motion prototype" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe><br/><br/>

ビルにある複数の窓とエントランスのドアのどれかからどれかへランダムに移動します。
いちいち手で動きつけてられないのでプログラムでなんとかします。
窓の位置を適当にモデリングします。
![windows](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/cdf5ae49-b3be-c580-8d37-819d620218e0.png)
Blender のメッシュ情報を読んで窓位置のセンターを JSON に書き出す Python プログラムを書きます。
```py
import bpy
import json
import os

def convert_vertex(co):
    return [co[0], co[2], -co[1]]

objs = bpy.context.selected_objects

all = {}
for obj in objs:
    if obj.type != "MESH":
        continue

    data = {}
    m = obj.matrix_world
    for p in obj.data.polygons:
        normal = obj.rotation_euler.to_matrix() @ p.normal
        data["normal"] = convert_vertex(normal)
        data["vertices"] = [
            convert_vertex(m @ obj.data.vertices[i].co) for i in p.vertices
        ]
        data["center"] = convert_vertex(m @ p.center)
    all[obj.name] = data

path = os.path.join(os.path.expanduser("~/Desktop/"), "ghost-windows.json")
with open(path, "w") as f:
    json.dump(all, f, indent=4)

```

窓の座標がわかったのでそれらをつなぐスプライン曲線を書きます。
普通のベジェ曲線だとなめらかに複数セグメントをつないでくのがめんどいので、以前 C# で書いた Natural Cubic Spline を Swift に移植しました。

:::note info
Natural Cubic Spline てのは C2 continuity な曲率の変化がなめらかなスプライン。Illustratorとかで適当にベジェ曲線を描くとハンドルが直線でも各セグメント間の曲率が急激に変わるので実はそんなになめらかじゃない。というのをベジェ曲線ベースで [VR ジェットコースター](https://whatever.co/ja/work/yahoo-trend-coaster/)のレールを設計して乗って気づきました。
:::

ビルの形状が上から見ると扇型になっているのでその中心と窓座標からいい感じに座標を計算してスプラインのコントロールポイントにします。
![path](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/a3b9d9f5-2bf3-1907-8e68-fc9d718b92ff.png)
コード的にはこんな感じ。

```swift
static func createFlyLine(start: SCNVector3, goal: SCNVector3) -> NaturalCubicSpline {
    let v = start - GhostSceneController.origin
    let d = sqrt(v.x * v.x + v.z * v.z) + 5
    let a = atan2(start.z - GhostSceneController.origin.z, start.x - GhostSceneController.origin.x)
    let b = atan2(goal.z - GhostSceneController.origin.z, goal.x - GhostSceneController.origin.x)
    let c = (a + b) / 2
    let middle = SCNVector3(cos(c) * d, (start.y + goal.y) / 2, sin(c) * d) + GhostSceneController.origin

    let startVector = v.normalized()
    let goalVector = (goal - GhostSceneController.origin).normalized()

    let points = [
        start + startVector * -3.0,
        start,
        start + startVector * 2.0,
        middle,
        goal + goalVector * 2.0,
        goal,
        goal + goalVector * -3.0,
    ]
    return NaturalCubicSpline(points: points)
}
```

あとは適当にこのスプライン曲線上を動かします。
ただし直接このスプライン曲線から取得した座標値をおばけオブジェクトに割り当てると、逆からきたおばけや混雑した場合にすり抜けてしまいます。（おばけなのでありかも…
なので、実際にはおばけオブジェクトには球体の当たり判定（`SCNPhysicsBody`）を設定してその球体をひっぱるようにして動かしています。（最初の動画の8秒あたり、上からきたのと下からきたのがコツっとあたっています。

# おばけが掃除機に吸い込まれる

掃除機がおばけの方向を向いた瞬間、おばけは掃除機に吸い込まれていきます。
初期実装では窓から窓へ移動するのと同じく、吸い込まれた瞬間の座標から掃除機中心座標に向けたスプライン曲線を設定してそのスプラインに沿って動かしていました。<br/><br/>

<iframe width="100%" height="700" src="https://www.youtube.com/embed/RUZKIMJ0KHQ?rel=0" title="Vacuuming motion 1" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe><br/><br/>

でも NG が出たので別の動かし方を考えました。
Swift で直接動きを定義しました。
```swift
func calculateTornadoPosition(t: Float) -> simd_float3 {
    let start = simd_make_float3(simd_mul(self.inverseCleanerTransform, simd_make_float4(self.tornadoStart, 1.0)))
    let d = simd_float3(0, -1, 0) - start
    let r = sqrt(d.x * d.x + d.z * d.z)
    let a = atan2(d.z, d.x)
    let tt = 1.0 - t
    let rr = max(0.1, r * powf(tt, 2.5))
    let aa = a - powf(t, 2.5) * self.tornadoAngle
    let p = simd_float4(rr * -cos(aa), start.y + d.y * t, rr * -sin(aa), 1.0)
    return simd_make_float3(simd_mul(self.cleanerTransform, p))
}
```
図にするとこんな感じ。
![tornado.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2595/f0ece3c2-d2f1-b598-9f3c-05b9a7df7e3b.png)
この動きだけでは魔封波っぽい吸い込まれた感じにならないのでメッシュを変形させてさらに吸い込まれ感を演出しました。
メッシュ変形には `SCNMaterial` の `shaderModifier` を使いました。[Shader Modifier](https://developer.apple.com/documentation/scenekit/scnshadable#1654834) は Metal のシェーダーを全部書かなくても必要な部分だけ書き換えられるので便利です。（さらに GLSL から Metal に変換もしてくれたりします。
```glsl
uniform mat4 u_tornadoTransform;
uniform mat4 u_inverseTornadoTransform;
uniform float enableTornado;

if (enableTornado > 0.0) {
    float4 pos = u_inverseTornadoTransform * u_modelTransform * _geometry.position; // convert to tornado local coords
    pos.y = max(0.0, pos.y);
    float y = pos.y * 0.35;
    float limit = y * y * 0.35 + 0.2;
    float s = min(1.0, limit);
    float r = sqrt(pos.x * pos.x + pos.z * pos.z) * s;
    r = min(r, limit);
    float a = atan2(pos.z, pos.x);
    float t = max(0.0, min(1.0, (5.0 - y) / 5.0));
    //a -= pow(t, 3.0) * 8.0;
    pos.x = cos(a) * r;
    pos.z = sin(a) * r;
    _geometry.position = u_inverseModelTransform * u_tornadoTransform * pos; // convert back to model coords
}
```
吸い込まれモーションの Swift コードと近くて掃除機に近づくにつれて回転角が増えて回転半径が小さくなっていきます。ここまで実装するとこうなります。<br/><br/>

<iframe width="100%" height="700" src="https://www.youtube.com/embed/2dyiBocWD7A?rel=0" title="Vacuuming motion 5" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe><br/><br/>

あとはパラメータ微調整して完成。

# 掃除機がおばけを飲み込む

吸い込まれたあとにホースがふくらんでゴクリと飲み込む感じが出したいということでエフェクトを追加しました。
これは簡単でホースのメッシュを通過位置に応じて中心から外に移動させればいいだけです。ただし単純に実装してしまうと同時に1箇所しか膨らませられなくなってしまうのでそこはちょっとアイデアが必要でした。

```swift
func update() {
    let now = CFAbsoluteTimeGetCurrent()
    let dt = now - simd_double4(startTime[0], startTime[1], startTime[2], startTime[3])
    var p = simd_float4(dt) * 4.0
    let data = Data(bytes: &p, count: MemoryLayout<simd_float4>.size)
    for material in materials {
        material.setValue(data, forKey: "position")
    }
}
```
```glsl
uniform vec4 position;
float l = length(_geometry.position.xy);
float p = smoothstep(0.2, 0.1, l);
vec4 d = (cos(clamp(_geometry.position.z + 0.9 - position, vec4(0.0), vec4(1.0)) * pi * 2 + pi) + 1.0) * 1.3 * p;
//_geometry.position.xy *= max(d.x, max(d.y, max(d.z, d.w)));
_geometry.position.xy *= d.x + d.y + d.z + d.w + 1.0;
```
コード的にはこうなっていて `position` に4つの通過開始時刻を押し込んで、Geometry の Shader Modifier で4箇所全部膨らませて合計しています。<br/><br/>

<iframe width="100%" height="700" src="https://www.youtube.com/embed/eRdus8fiZAY?rel=0" title="Drinking" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe><br/><br/>

最初はこの動画のように膨らむ最大値を決めてたのだけど（↑のコードのコメントアウトしてるほう）、複数同時に通ったときによりデカくなっていいかなーと合計することにしました。

# ホースがちょい揺れるとか

ずっと画面に固定されてるのがアレだなーとホースが追随してくるような動きを実装しようと思ってたけど時間切れ〜。

（エフェクト作るのは楽しいな〜