MoAR のコンテンツのひとつ、"Ghost Building" に出てくるおばけのモーションを作ったときのお話。
おばけが窓から窓へ移動する
ビルにある複数の窓とエントランスのドアのどれかからどれかへランダムに移動します。
いちいち手で動きつけてられないのでプログラムでなんとかします。
窓の位置を適当にモデリングします。
Blender のメッシュ情報を読んで窓位置のセンターを JSON に書き出す Python プログラムを書きます。
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 に移植しました。
Natural Cubic Spline てのは C2 continuity な曲率の変化がなめらかなスプライン。Illustratorとかで適当にベジェ曲線を描くとハンドルが直線でも各セグメント間の曲率が急激に変わるので実はそんなになめらかじゃない。というのをベジェ曲線ベースで VR ジェットコースターのレールを設計して乗って気づきました。
ビルの形状が上から見ると扇型になっているのでその中心と窓座標からいい感じに座標を計算してスプラインのコントロールポイントにします。
コード的にはこんな感じ。
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秒あたり、上からきたのと下からきたのがコツっとあたっています。
おばけが掃除機に吸い込まれる
掃除機がおばけの方向を向いた瞬間、おばけは掃除機に吸い込まれていきます。
初期実装では窓から窓へ移動するのと同じく、吸い込まれた瞬間の座標から掃除機中心座標に向けたスプライン曲線を設定してそのスプラインに沿って動かしていました。
でも NG が出たので別の動かし方を考えました。
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))
}
図にするとこんな感じ。
この動きだけでは魔封波っぽい吸い込まれた感じにならないのでメッシュを変形させてさらに吸い込まれ感を演出しました。
メッシュ変形には SCNMaterial
の shaderModifier
を使いました。Shader Modifier は Metal のシェーダーを全部書かなくても必要な部分だけ書き換えられるので便利です。(さらに GLSL から Metal に変換もしてくれたりします。
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 コードと近くて掃除機に近づくにつれて回転角が増えて回転半径が小さくなっていきます。ここまで実装するとこうなります。
あとはパラメータ微調整して完成。
掃除機がおばけを飲み込む
吸い込まれたあとにホースがふくらんでゴクリと飲み込む感じが出したいということでエフェクトを追加しました。
これは簡単でホースのメッシュを通過位置に応じて中心から外に移動させればいいだけです。ただし単純に実装してしまうと同時に1箇所しか膨らませられなくなってしまうのでそこはちょっとアイデアが必要でした。
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")
}
}
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箇所全部膨らませて合計しています。
最初はこの動画のように膨らむ最大値を決めてたのだけど(↑のコードのコメントアウトしてるほう)、複数同時に通ったときによりデカくなっていいかなーと合計することにしました。
ホースがちょい揺れるとか
ずっと画面に固定されてるのがアレだなーとホースが追随してくるような動きを実装しようと思ってたけど時間切れ〜。
(エフェクト作るのは楽しいな〜