🐡

東京タワー

に公開

はじめに

概要

江戸・東京の魅力発信ワールドに向けてワールド制作を行った過程をきろくしました。

ワールドを作るきっかけ

リアル勉強会 Gotanda.clusterに参加した日の午前に東京タワーを観光していました。せっかくなので東京タワーを取り込んだワールド制作をしようと考えました。
観光では展望台まで上がったのですが、平日のために階段で上がることができず、しぶしぶエレベータで到達しました。もともとクレしんの映画に影響を受けていて、東京タワーを観光するときは階段で上がり上がりたかった。。。
ほかに、東京タワーを見上げる幾何模様が綺麗だったのですが、別の視点から見てみたいなぁと思っていました。

経緯

2024年11月21日(木)に五反田集合ということで、その日の午前中に東京観光をしていました。
最初はテーマもなく取り組んでいました。

環境構築

開発環境(ワールド作り)

  • Unity 2021.3.4f1
  • Windows10
  • Cluster Creator Kit 2.30.0
    • 特に決めていない、最新であればよい
    • ベータ版→2024/1/6にCollide周辺の仕様変更有り。下記のスクリプトは動作しないです。。。

開発環境(空間づくり)

空間づくり

  • 東京タワーの取得
    PLATEAU SDK for Unityで東京タワーのデータを取得しました。(メッシュだけ取得するのはもうお手の物です)
    今回頑張った点は、タワーに色を付けたところです。(PLATEAU SDK for Unityでテクスチャーを含むとiOS向けビルドでエラーになるため、メッシュだけしか活用していませんでした)
    カラーつきにするため、Blenderに取りこんで自分でマテリアルマッピングしました(用語が正しいかはわかっていないです。。。)
  • テクスチャー準備
    上記の通り、Plateau SDKで取得した東京タワーをfbxとしてエクスポートし、blenderにインポートします。
    blenderでオブジェクトを選択し、編集モードの状態でUV編集を押します。

    マテリアルタブから、+で新規マテリアルを作成します。

    ベースカラーで画像テクスチャを選択します。

    pngファイルで貼り付ける色を用意します。
    編集モードで面選択して、UV編集で適切な色になるようにUV座標を修正します。(つらい)

ワールドづくり

  • 企画参加の準備
    2024年年末ごろ、SNSで公式・非公式の企画がありました。
    江戸・東京の魅力発信ワールドはたまたまSNSで見かけた企画です。東京タワー出していたら応募要項を満たすと思いました。
    ワールドを作り始めのときは「デスゲームジャム」2025年1月開催!!に投稿してみたいと思いました。その要項を満たすように、監視カメラの機能を作ろうとしました。(新規ワールドが問題ではなさそう、、とおもっていました)
    Render Textureで作る監視カメラギミックにある通りです。RenderTextureはプロジェクトから作成できます。

    作成したRenderTextureをカメラにはターゲットテクスチャーとして、スクリーンにはマテリアルとして関連付ければ、簡単に監視カメラを作成できました。

  • エレベータづくり

    • 衝突の設定
      Blenderでエレベータのようなモデルを作成しました。正直箱と円柱を組み合わせただけですので割愛します。(つらい)
      エレベータの中にアバターが入ったら何かおこってほしいよなぁと思い、スクリプトを書きたいと思いました。
      本当はアイテムやコライダーの「トリガー」でワールドのギミックを動かすのようなコンポーネントによって、スクリプトいらずに準備したいのですが。イベントの送信・受信とそれを受けてプレイヤーへアクションさせる処理をUnityで実装するのはつらい(つらい)。
      希望としては、OnUpdate関数内で衝突しているプレイヤーを取得する処理は書きたくないので、調べてみるとonCollideというコールバック関数がありました。
      しかし、2025年1月1日時点でbeta機能であるonCollideをエラー無く呼び出せる人類はいないと思われます(N敗)。。。
$.onCollide(collision => {
    if (collision.handle?.type === "player") {
        let playerHandle = collision.handle;
        $.log("collide with a player: ") + playerHandle;
    }
});

↑全然わかりません。。。
諦めてonUpdateで$.getOverlaps();を呼ぶのが素直に良いと思われます。


衝突させるオブジェクトにはOverlap Detector Shapeを付与します。

オブジェクトが移動するならMovable Itemを付与します。
これらのコンポーネントは、スクリプトで動かしたときに動かない時にの必要性を思い出します(2敗)。
* スクリプト(ジャンプ)
衝突したらジャンプする、というスクリプトは既に実装していました。今回はClusterScriptでも高階関数を使うのですを参考にして、役割を分離するために高階関数を用意しました。(関数をトップで呼び出すときに$アクセスすると実行時に怒られるので注意。。。)
オブジェクトインスタンスとログ出力インスタンスが同じでいいのか、とか変数名の規約とかいろいろ気になってしまうのですが、Javascriptでは何が一般的なんですかねぇ。。。


$.onStart(() => {
    $.state.initialPosition = $.getPosition();
});

const onCollide = () => {
    let overlapPlayers = [];
    const velocity = new Vector3(4, 4, 0);
    // onCollide = ($) => { // とか受け取って、
    // const velocity =  $.getPosition(); // と初期化すると実行時に怒られる。。。
    return (collisions, $) => { 
        // 前のフレームで接触しpreviousOverlapPlayersていたプレイヤーIDの一覧
        let previousOverlapPlayers = overlapPlayers;
        // $.log("previousOverlapPlayers: " + previousOverlapPlayers);
        
        // このフレームで接触しているプレイヤーIDの一覧
        let currentOverlapPlayers = [];

        collisions.forEach(collision => {
            // 接触しているオブジェクトがプレイヤーであるかどうかを確認
            let playerHandle = collision?.object?.playerHandle;
            $.log("playerHandle: " + playerHandle);
            $.log(" playerHandle?.type: " + playerHandle?.type);
            if (playerHandle == null || playerHandle?.type !== "player") return;
            // 現在接触しているプレイヤーの一覧に追加
            $.log("playerHandle.id: " + playerHandle.id);
            currentOverlapPlayers.push(playerHandle.id);

            // 前のフレームで接触していたプレイヤーは除外
            // playerHandle.addVelocityの実行には頻度制限があるためその対策、また接触し続けた場合に加速し続けてしまうことを防止
            if (previousOverlapPlayers.includes(playerHandle.id)) return;

            // プレイヤーに一定の速度を加えて打ち上げる
            playerHandle.addVelocity(velocity);
        });
        overlapPlayers = currentOverlapPlayers;
    }
};

const trapezoidalWave = (t) => {
    if (t < 0.25) {
        return t * 4;
    } else if (t < 0.5) {
        return 1;
    } else if (t < 0.75) {
        return 3 - t * 4;
    } else {
        return 0;
    }
};

const Move = () => {
    let time = 0;
    const width = 2;
    const maxPeriod = 6;
    const minPeriod = 4;
    const period = Math.floor(Math.random() * maxPeriod) + minPeriod;
    return (MovableItem, deltaTime, $) => {
        const itemPosition = $.state.initialPosition;
        const shift = new Vector3(
            trapezoidalWave(time % period / period) * width - width / 2,
            0,
            0);
        // $.log("time: " + time);
        // $.log("itemPosition: " + itemPosition);
        // $.log("shift: " + shift);
        let current = itemPosition.clone();
        current.add(shift)
        MovableItem?.setPosition(current);
        time += deltaTime;
        time = time;
    }
};

const handleMove = Move();
const handleCollisions = onCollide();
$.onUpdate(deltaTime => {
    let overlaps = $.getOverlaps();
    handleCollisions(overlaps, $);
    handleMove($, deltaTime, $);
});
* スクリプト(回転)

回転するスクリプトも既に実装を持っていました。そのときはcos, sinで方向ベクトルを自前で書いていました。今回はClusterScriptの「Quaternion」を完全に理解する(完全に理解するとは言っていない)を参考に、適当に回転させます。(クオータニオン、右からかけるか、左からかけるか)

const Rotate = () => {
    const ANGULAR_VELOCITY = 30;
    let _rotation = null;
    return (movableItem$, deltaTime) => {
        const currentRotation = movableItem$.getRotation();
        const degreeDifference = ANGULAR_VELOCITY * deltaTime;
        const rotationDifference = new Quaternion().setFromEulerAngles(new Vector3(0, 0, degreeDifference));
        const newRotation = rotationDifference.clone().multiply(currentRotation);

        movableItem$?.setRotation(newRotation);
        // _rotation = newRotation;
    }
};

const handleRotate = Rotate();
$.onUpdate(deltaTime => {
    handleRotate($, deltaTime);
});
  • (コラム)Blenderで3Dテキストを作成
    Blenderで3Dテキストを作成する方法の通りです。縦書きがないので改行でごまかしました。日本語文は直接入力すると受け付けないので、エディタに入力したテキストをペーストしました。(なんなんだ。。。)

  • (コラム)Blenderで断面
    参考モデルの断面図を確認する方法【ブーリアン】みたいなハックをしました。断面の機能ってBlenderにないのだろうか、つらい。
    この断面の機能は便利ですが、エクスポートするときに可視状態のみ対象にしないと表示されてしまいます(1敗)。

  • 地球儀
    東京といえばなんでしょうか。ただし、モデリングが簡単なもので(エレベータ制作で絶望済)。
    ChatGPTに伺ったところ、日本未来科学館にある地球儀っぽいモニュメント(ジオ・コスモス)らしいです(なにそれ)。
    リアルでは天井からぶら下がっているらしいので、仮想世界ではたくさん落下させようと思いました。
    シュートができるバスケットボールをつくってみよう【cluster用アイテム】をみてボールが跳ねる設定をしました。初めてPhysic Materialを使いました。。。

真っ白な球でもよかったのですが、さすがに地球儀にはみえないので、
visible earth
から画像を利用しました。(ナーサー)

  • バンジージャンプ
    東京タワーでやることといえばバンジージャンプくらいでしょう(そうでしょう)。
    素晴らしいことに、実現したい挙動で調べてみるとその方法が記事になっていました。僕は創意工夫が苦手なので助かります。ただ、創意工夫したいなぁって指をくわえて眺めています。(そもそもCCK+UnityでUnityを使うようになったので、(C#を使えない)Unityで何ができるのかわかりません。。。。。。)
    Unityの「ジョイント」を使ってバンジージャンプをつくってみよう

バンジージャンプを実装したとき、バンジー後のアイテムがそのままの位置にあったので
アイテムを元の位置に戻すギミックをつくるを参考に元の位置に戻しました。細かいところとしてはアイテムがぶつかって移動したときは元の位置に戻らないみたいです。。まぁ、いいか。

  • バトル要素
    デスバトル要素のために、プレイヤーを階段から吹き飛ばせるといいと思いました。
    プレイヤーを吹き飛ばす(プレイヤー操作)を参考にしてつくったコード残しておきます。残念ですが、サブ垢がないので動作確認していません。(勘弁して)
$.onStart(() => {
    $.state.grabbingPlayer = null;
    $.state.grabbingLeft = true;
});

$.onGrab((isGrab, isLeft, player) => {
    if (isGrab) $.state.grabbingPlayer = player;
    else $.state.grabbingPlayer = null;
    $.state.grabbingLeft = isLeft;
});

const onCollide = () => { 
    let _overlapPlayers = [];
    const speed = 5;
    const dirY = new Vector3(0, -1 * speed,0);
    return ($) => {
        let grabbingPlayer = $.state.grabbingPlayer;
        if (grabbingPlayer == null || !grabbingPlayer.exists()) {
            _overlapPlayers = [];
            return;
         }
        const grabbingLeft = $.state.grabbingLeft;
        let grabbingPlayerPosition = grabbingPlayer.getPosition();
        let previousOverlapPlayers = _overlapPlayers;
        let currentOverlapPlayers = [];

        let overlaps = $.getOverlaps();
        overlaps.forEach(overlap => {
            let playerHandle = overlap.object.playerHandle;
            if (playerHandle == null) return;
            if (playerHandle.id == grabbingPlayer.id) return;

            currentOverlapPlayers.push(playerHandle.id);
            if (previousOverlapPlayers.includes(playerHandle.id)) return;
            if (grabbingLeft) {
                let direction = playerHandle.getPosition().clone().sub(grabbingPlayerPosition).normalize();
                playerHandle.addVelocity(direction.clone().multiplyScalar(speed));
            }
            else {
                playerHandle.addVelocity(dirY);
             }
        });

        _overlapPlayers = currentOverlapPlayers;
    }
};

const handleCollisions = onCollide();
$.onUpdate(deltaTime => {
    handleCollisions(grabbingPlayer, overlaps);
});
  • ベイク
    公開して気が付くのですが、スマホプレイヤーでのワールドが暗すぎます。。
    ライトベイクの基本を学ぼう
    のとおり、ライトベイクしておきましょう。。。

おわりに

2025年1月4日、デスゲームジャム 企画書が公開されました。デスゲームジャムの内容を読むと、ワールド制作期間が1/10(金) 21:00~1/20(月) 21:00ぽいことに気が付きました。「期間内にゲームを制作する緊張」とあるので事前に作成したらよくないよね。ということでデスゲームジャム向けに別途ワールド制作します(1敗)。

追記
ワールド名のいい案が思いつかなかった。。。

2025/01/06

衝突判定に関するベータ機能を正式化しました!他【Cluster Creator Kit v2.30.1 リリースノート】
の通り、Creator Kit v2.30.1ではOverlap.objectやCollision.objectが廃止されます。そのため↑で書かれたスクリプトを実行してもエラーになります
プレイヤーを吹き飛ばす(プレイヤー操作)相当のスクリプトだけ添付しておきます。


$.onStart(() => {
    $.state.grabbingPlayer = null;
    $.state.grabbingLeft = true;
});

$.onGrab((isGrab, isLeft, player) => {
    if (isGrab) $.state.grabbingPlayer = player;
    else $.state.grabbingPlayer = null;
    $.state.grabbingLeft = isLeft;
});

const onCollide = () => { 
    let _overlapPlayers = [];
    const speed = 5;
    const dirY = new Vector3(0, -1 * speed,0);
    return ($) => {
        let grabbingPlayer = $.state.grabbingPlayer;
        if (grabbingPlayer == null || !grabbingPlayer.exists()) {
            _overlapPlayers = [];
            return;
         }
        const grabbingLeft = $.state.grabbingLeft;
        let grabbingPlayerPosition = grabbingPlayer.getPosition();
        let previousOverlapPlayers = _overlapPlayers;
        let currentOverlapPlayers = [];

        let collisions = $.getOverlaps();
        for (let collision of collisions) {
            let playerHandle = collision.handle;
            if (playerHandle == null || playerHandle?.type !== "player") return;
            if (playerHandle.id == grabbingPlayer.id) return;

            currentOverlapPlayers.push(playerHandle.id);
            if (previousOverlapPlayers.includes(playerHandle.id)) return;
            if (grabbingLeft) {
                let direction = playerHandle.getPosition().clone().sub(grabbingPlayerPosition).normalize();
                playerHandle.addVelocity(direction.clone().multiplyScalar(speed));
            }
            else {
                playerHandle.addVelocity(dirY);
             }
        }

        _overlapPlayers = currentOverlapPlayers;
    }
};
const handleCollisions = onCollide();

$.onUpdate(deltaTime => {
    handleCollisions($);
});

$.getOverlaps()の各要素のプロパティでobject.playerHandleで取得していたものが、handleで取り直すように変更しています。そのhandle.typeが"item"あるいは"player"で分岐をしています。
今回はプレイヤーをぶっ飛ばすので"player"での分岐が必要という感じですね。
これまで公開されてきた記事を参考にすると詰む感じです。。。

Discussion