跳到正文
Qiita · Unity 开发社区· 来源· Rikuto_q·· 2 小时前AI 评分68

Unity XR盆栽应用开发Part 2:手势交互与动态形状生成实战

【Part 2】ハンドトラッキングで行う盆栽育成アプリの開発

正文 · 原文

0

Last updated at 2026-11-13Posted at 2026-11-13

はじめに

本記事は、XR空間で盆栽の手入れを行うアプリ開発の 第2回(機能実装編) です。

前回の「環境構築・ハンドジェスチャー検知編の手順をベースに実装を進めます。まだご覧になっていない方は、まず第1回をご確認ください。

最終的に実装した機能一覧

  • 動的メッシュによる盆栽の形状生成:
    • Catmull-Rom スプラインを用いて、制御点から幹・枝の立体メッシュをリアルタイムに生成・変形
  • 段階的な成長ロジック
    • 成長ステップに応じて幹の肥大化や枝の分岐・伸長が行われる仕組み
  • ハンドトラッキングによるお手入れ:
    • 水やり
      • ジョウロの傾き検知による水滴オブジェクト生成と、盆栽へ接触
    • 曲げ
      • ピンチ操作による制御点の移動と枝の変形
    • カット(剪定)
      • 指先の位置判定とピンチジェスチャーによる枝・葉の切り落とし
  • UIによるお手入れモード切り替え
    • 左手のひらで表示されるUIに触れて「曲げ・剪定・水やり・etc」の各モードを切り替え
  • 盆栽データのセーブ・ロード
    • ツリー構造の枝データと切り落とした葉の記録をJSON形式で保存・復元
  • 空間メッシュによる現実空間への配置
    • Meta MR Utility Kit を用い、スキャンした現実の机の上に盆栽を設置

今回使用した3Dモデル

Blenderを用いてモデルからテクスチャまで私が作成しました。

  • 鉢

  • モミジの葉

  • ハサミ

  • ジョウロ

盆栽形状の生成

曲線の周辺にメッシュを肉付けすることで幹と枝を立体で表現します。曲線は空間上に配置された制御点を結ぶ Catmull-Rom スプラインをコードで作っています。

お手入れで枝を曲げたり、カットした際に合わせて制御点を動かしたり削除することで曲線からメッシュの生成を再計算し、盆栽の形状の変化を表現しています。

制御点にオブジェクトとコントローラーを配置して動かしてみます。

「BonsaiShapeGenerator」主要のコードを表示

using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public class BonsaiShapeGenerator : MonoBehaviour
{
    // ==========================================
    // 1. 盆栽の骨格を構成するデータ構造
    // ==========================================

    [System.Serializable]
    public class SplineKnot
    {
        public Vector3 localPosition; // 親基準のローカル座標
        public float radius;          // この節における太さ(半径)

        public SplineKnot(Vector3 pos, float r)
        {
            localPosition = pos;
            radius = r;
        }
    }

    [System.Serializable]
    public class SplineBranch
    {
        public string name = "Branch";
        [Range(0f, 1f)] public float attachT = 0.55f; // 親スプライン上の接続位置 (0.0〜1.0)
        public List<SplineKnot> knots = new List<SplineKnot>();

        [SerializeReference]
        public List<SplineBranch> childBranches = new List<SplineBranch>(); // 再帰的な子枝ツリー
    }

    // 補間後の曲線上の各サンプル点における3次元姿勢フレーム
    public class SplineFrame
    {
        public Vector3 position;
        public Vector3 tangent;  // 進行方向
        public Vector3 normal;   // 法線
        public Vector3 binormal; // 従法線
        public float radius;

        public SplineFrame(Vector3 pos, Vector3 tan, float r)
        {
            position = pos;
            tangent = tan;
            radius = r;
        }
    }

    // ==========================================
    // 2. メッシュ設定と骨格データ
    // ==========================================

    [Header("メッシュ解像度")]
    [SerializeField, Range(4, 16)] private int radialSegments = 8;
    [SerializeField, Range(4, 30)] private int samplesPerCurve = 12;

    [Header("中心スプラインデータ")]
    [SerializeField] private List<SplineKnot> trunkKnots = new List<SplineKnot>();
    [SerializeReference] private List<SplineBranch> rootBranches = new List<SplineBranch>();

    private Mesh dynamicMesh;

    // ==========================================
    // 3. メッシュ再構築(主幹 + 再帰的な子枝)
    // ==========================================

    public void RebuildMesh()
    {
        if (trunkKnots == null || trunkKnots.Count < 2) return;

        if (dynamicMesh == null)
        {
            dynamicMesh = new Mesh { name = "Dynamic Bonsai Mesh" };
            dynamicMesh.MarkDynamic();
            GetComponent<MeshFilter>().sharedMesh = dynamicMesh;
        }

        List<Vector3> vertices = new List<Vector3>();
        List<int> triangles = new List<int>();
        List<Vector2> uvs = new List<Vector2>();

        // 1. 主幹(Trunk)のフレーム計算とチューブメッシュ生成
        List<SplineFrame> trunkFrames = EvaluateCatmullRomSpline(trunkKnots, samplesPerCurve, null);
        BuildTubeMesh(trunkFrames, vertices, triangles, uvs);

        // 2. 主幹から分岐する全枝を再帰的にメッシュ化
        if (rootBranches != null)
        {
            foreach (var branch in rootBranches)
            {
                BuildBranchMeshRecursive(branch, trunkFrames, vertices, triangles, uvs);
            }
        }

        // メッシュバッファへ反映
        dynamicMesh.Clear();
        dynamicMesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        dynamicMesh.SetVertices(vertices);
        dynamicMesh.SetUVs(0, uvs);
        dynamicMesh.SetTriangles(triangles, 0);
        dynamicMesh.RecalculateNormals();
        dynamicMesh.RecalculateBounds();
    }

    private void BuildBranchMeshRecursive(SplineBranch branch, List<SplineFrame> parentFrames, List<Vector3> verts, List<int> tris, List<Vector2> uvs)
    {
        if (branch.knots.Count < 2 || parentFrames == null || parentFrames.Count < 2) return;

        // 親スプライン上の接続位置(attachT)の姿勢を取得し、それを基準に子枝の座標を展開
        SplineFrame attachFrame = SampleFrameAtT(parentFrames, branch.attachT);
        List<SplineFrame> branchFrames = EvaluateBranchSpline(branch.knots, attachFrame, samplesPerCurve);
        BuildTubeMesh(branchFrames, verts, tris, uvs);

        // さらに分岐する孫枝へ再帰
        foreach (var child in branch.childBranches)
        {
            BuildBranchMeshRecursive(child, branchFrames, verts, tris, uvs);
        }
    }

    // ==========================================
    // 4. チューブメッシュ(筒状ポリゴン)生成
    // ==========================================

    private void BuildTubeMesh(List<SplineFrame> frames, List<Vector3> verts, List<int> tris, List<Vector2> uvs)
    {
        int startIndex = verts.Count;
        float totalLength = 0f;
        List<float> lengths = new List<float>(frames.Count) { 0f };

        for (int i = 1; i < frames.Count; i++)
        {
            totalLength += Vector3.Distance(frames[i].position, frames[i - 1].position);
            lengths.Add(totalLength);
        }

        // 各フレームの法線・従法線平面上に円周頂点を配置
        for (int i = 0; i < frames.Count; i++)
        {
            SplineFrame frame = frames[i];
            float v = (totalLength > 0f) ? lengths[i] / totalLength : 0f;

            for (int j = 0; j <= radialSegments; j++)
            {
                float angle = (float)j / radialSegments * Mathf.PI * 2f;
                Vector3 offset = (frame.normal * Mathf.Cos(angle) + frame.binormal * Mathf.Sin(angle)) * frame.radius;
                verts.Add(frame.position + offset);
                uvs.Add(new Vector2((float)j / radialSegments, v * 4f));
            }
        }

        // 隣接リング間を三角形ポリゴンで接続
        int ring = radialSegments + 1;
        for (int i = 0; i < frames.Count - 1; i++)
        {
            int r0 = startIndex + i * ring;
            int r1 = startIndex + (i + 1) * ring;

            for (int j = 0; j < radialSegments; j++)
            {
                int current = r0 + j;
                int next = r1 + j;
                tris.Add(current); tris.Add(next); tris.Add(current + 1);
                tris.Add(next); tris.Add(next + 1); tris.Add(current + 1);
            }
        }

        // 枝先の断面を閉じるキャップポリゴン
        SplineFrame tipFrame = frames[frames.Count - 1];
        int tipCenterIndex = verts.Count;
        verts.Add(tipFrame.position);
        uvs.Add(new Vector2(0.5f, 0.5f));

        int lastRingStart = startIndex + (frames.Count - 1) * ring;
        for (int j = 0; j < radialSegments; j++)
        {
            tris.Add(tipCenterIndex);
            tris.Add(lastRingStart + j);
            tris.Add(lastRingStart + j + 1);
        }
    }

    // ==========================================
    // 5. Catmull-Rom スプライン補間 & 姿勢計算
    // ==========================================

    private static List<SplineFrame> EvaluateBranchSpline(List<SplineKnot> knots, SplineFrame baseFrame, int resolution)
    {
        List<SplineKnot> orientedKnots = new List<SplineKnot>(knots.Count);
        Quaternion baseRot = Quaternion.LookRotation(baseFrame.tangent, baseFrame.normal);
        orientedKnots.Add(new SplineKnot(baseFrame.position, knots[0].radius));

        for (int i = 1; i < knots.Count; i++)
        {
            Vector3 offset = baseRot * knots[i].localPosition;
            orientedKnots.Add(new SplineKnot(baseFrame.position + offset, knots[i].radius));
        }

        return EvaluateCatmullRomSpline(orientedKnots, resolution, baseFrame.normal);
    }

    private static List<SplineFrame> EvaluateCatmullRomSpline(List<SplineKnot> knots, int resolution, Vector3? initialNormalOverride)
    {
        int numSections = knots.Count - 1;
        List<SplineFrame> frames = new List<SplineFrame>(numSections * resolution + 1);

        for (int i = 0; i < numSections; i++)
        {
            Vector3 p0 = (i > 0) ? knots[i - 1].localPosition : knots[i].localPosition;
            Vector3 p1 = knots[i].localPosition;
            Vector3 p2 = knots[i + 1].localPosition;
            Vector3 p3 = (i + 2 < knots.Count) ? knots[i + 2].localPosition : p2;

            for (int step = 0; step < resolution; step++)
            {
                float t = (float)step / resolution;
                Vector3 pos = GetCatmullRomPosition(t, p0, p1, p2, p3);
                Vector3 tangent = GetCatmullRomTangent(t, p0, p1, p2, p3).normalized;
                float r = Mathf.Lerp(knots[i].radius, knots[i + 1].radius, t);
                frames.Add(new SplineFrame(pos, tangent, r));
            }
        }

        SplineKnot last = knots[knots.Count - 1];
        Vector3 endTangent = (last.localPosition - knots[knots.Count - 2].localPosition).normalized;
        frames.Add(new SplineFrame(last.localPosition, endTangent, last.radius));

        ComputeParallelTransportFrames(frames, initialNormalOverride);
        return frames;
    }

    // 平行移動フレーム法(Parallel Transport Frame)でメッシュの捩れを防止
    private static void ComputeParallelTransportFrames(List<SplineFrame> frames, Vector3? initialNormalOverride)
    {
        if (frames.Count == 0) return;

        Vector3 initialNormal;
        if (initialNormalOverride.HasValue && initialNormalOverride.Value.sqrMagnitude > 0.001f)
        {
            initialNormal = Vector3.ProjectOnPlane(initialNormalOverride.Value, frames[0].tangent).normalized;
            if (initialNormal.sqrMagnitude < 0.001f)
                initialNormal = Vector3.Cross(frames[0].tangent, Vector3.up).normalized;
        }
        else
        {
            initialNormal = Vector3.Cross(frames[0].tangent, Vector3.right).normalized;
            if (initialNormal.sqrMagnitude < 0.001f)
                initialNormal = Vector3.Cross(frames[0].tangent, Vector3.up).normalized;
        }

        frames[0].normal = initialNormal;
        frames[0].binormal = Vector3.Cross(frames[0].tangent, frames[0].normal).normalized;

        for (int i = 1; i < frames.Count; i++)
        {
            Vector3 v1 = frames[i - 1].tangent;
            Vector3 v2 = frames[i].tangent;
            Vector3 axis = Vector3.Cross(v1, v2);

            if (axis.sqrMagnitude < 1e-6f)
            {
                frames[i].normal = frames[i - 1].normal;
            }
            else
            {
                float angle = Vector3.Angle(v1, v2);
                Quaternion rot = Quaternion.AngleAxis(angle, axis.normalized);
                frames[i].normal = (rot * frames[i - 1].normal).normalized;
            }
            frames[i].binormal = Vector3.Cross(frames[i].tangent, frames[i].normal).normalized;
        }
    }

    private static SplineFrame SampleFrameAtT(List<SplineFrame> frames, float t)
    {
        t = Mathf.Clamp01(t);
        float indexF = t * (frames.Count - 1);
        int idx = Mathf.FloorToInt(indexF);
        float frac = indexF - idx;

        if (idx >= frames.Count - 1) return frames[frames.Count - 1];

        SplineFrame a = frames[idx];
        SplineFrame b = frames[idx + 1];

        Vector3 pos = Vector3.Lerp(a.position, b.position, frac);
        Vector3 tangent = Vector3.Lerp(a.tangent, b.tangent, frac).normalized;
        Vector3 normal = Vector3.Lerp(a.normal, b.normal, frac).normalized;
        Vector3 binormal = Vector3.Cross(tangent, normal).normalized;
        float r = Mathf.Lerp(a.radius, b.radius, frac);

        return new SplineFrame(pos, tangent, r) { normal = normal, binormal = binormal };
    }

    private static Vector3 GetCatmullRomPosition(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3)
    {
        return 0.5f * (
            (2f * p1) +
            (-p0 + p2) * t +
            (2f * p0 - 5f * p1 + 4f * p2 - p3) * (t * t) +
            (-p0 + 3f * p1 - 3f * p2 + p3) * (t * t * t)
        );
    }

    private static Vector3 GetCatmullRomTangent(float t, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3)
    {
        return 0.5f * (
            (-p0 + p2) +
            2f * (2f * p0 - 5f * p1 + 4f * p2 - p3) * t +
            3f * (-p0 + 3f * p1 - 3f * p2 + p3) * (t * t)
        );
    }
}


次に成長をできるようにします。
今回はモミジの盆栽を作成しているため、モミジの形状にあうように成長させていきます。
成長させる方法としては、その時の成長させるn回目に対してそれぞれ呼ぶ処理を変えて成長の仕方を決定しています。今回は幹や枝の伸び具合をコードで指定したため、成長過程で制御点を操作して形状を変えないと毎度同じ盆栽の形状へ成長していきます。
本来であればランダム性を持たせたいところですが、今回の開発では時間の関係もあり現状のまま進めていきます。

プレイヤーが成長を行うには、この先記述されている"水やり"ののち、UIの成長させるボタンを押すことで盆栽が成長するようにします。

「MomijiGrowLogic」主要コードを表示

/// <summary>
    /// BonsaiSplineDeformer から呼び出される成長メイン処理。
    /// ステップを進め、肥大成長と各段階に応じた処理を実行します。
    /// </summary>
    public void ExecuteGrow(BonsaiSplineDeformer tree)
    {
        if (tree == null) return;

        currentGrowthStep++;

        // 1. 肥大成長:既存の幹・枝全体を年輪を重ねるように太らせる
        ApplyThickeningGrowth(tree);

        // 2. 伸長成長:ステップに応じて先端が伸び、新しい枝が生える
        switch (currentGrowthStep)
        {
            case 1:
                // 幹が上へ伸びる(まだ細い若木)
                GrowTrunkToNode(tree, targetNodeCount: 3);
                break;

            case 2:
                // 幹がさらに伸び、一の枝・二の枝が細い新芽として萌芽
                GrowTrunkToNode(tree, targetNodeCount: 4);
                SpawnPrimaryBranch1(tree);
                SpawnPrimaryBranch2(tree);
                break;

            case 3:
                // 幹が中段へ到達。後ろ枝が萌芽し、主枝が伸びる
                GrowTrunkToNode(tree, targetNodeCount: 5);
                SpawnBackBranch(tree);
                ExtendBranch(FindBranchByName(tree.RootBranches, "一の枝(左主枝)"), 0.05f);
                ExtendBranch(FindBranchByName(tree.RootBranches, "二の枝(右主枝)"), 0.04f);
                break;

            case 4:
                // 三の枝が萌芽し、主枝から水平棚枝(一次側枝)が展開
                GrowTrunkToNode(tree, targetNodeCount: 6);
                SpawnPrimaryBranch3(tree);
                AddSubBranchesLevel1(tree);
                break;

            case 5:
                // 幹が頭頂部に到達。頭部枝が萌芽し、側枝から孫枝が対生分岐
                GrowTrunkToNode(tree, targetNodeCount: 7);
                SpawnCrownBranches(tree);
                AddSubBranchesLevel2(tree);
                break;

            case 6:
                // 頭部の枝を充実させ、全体に二股の小枝群を展開
                AddCrownSubBranches(tree);
                AddDenseTwigLayer(tree);
                break;

            case 7:
                // 大樹の風格。剪定(カット)の対象となる徒長枝・立ち枝が出現
                SpawnPruningTargetTwigs(tree);
                break;

            default:
                // 最大成長後の継続成長(微細な伸長と維持)
                ContinuousFineGrowth(tree);
                break;
        }
    }

    // ==========================================
    // 肥大成長(Secondary Growth)ロジック
    // ==========================================

    /// <summary>
    /// 木全体(幹と全階層の枝)を根元側から年輪のように太らせます。
    /// </summary>
    private void ApplyThickeningGrowth(BonsaiSplineDeformer tree)
    {
        // 1. 幹の肥大化(根元ほど大きく増加、先端は緩やか)
        for (int i = 0; i < tree.TrunkKnots.Count; i++)
        {
            var knot = tree.TrunkKnots[i];
            float maxRadius = (i < TrunkBlueprint.Length) ? TrunkBlueprint[i].maxRadius : 0.012f;

            float heightRatio = (float)i / Mathf.Max(1, TrunkBlueprint.Length - 1);
            float currentRate = Mathf.Lerp(trunkThickeningRate, trunkThickeningRate * 0.25f, heightRatio);

            knot.radius = Mathf.Min(maxRadius, knot.radius + currentRate);
        }

        // 2. 枝の肥大化(主枝から側枝、小枝まで再帰的に適用)
        foreach (var branch in tree.RootBranches)
        {
            ThickenBranchRecursive(branch, depth: 1);
        }
    }

    /// <summary>
    /// 枝およびその配下の子枝を再帰的に太らせます。
    /// </summary>
    private void ThickenBranchRecursive(BonsaiSplineDeformer.SplineBranch branch, int depth)
    {
        if (branch.knots.Count == 0) return;

        float depthDamp = Mathf.Pow(0.75f, depth - 1);

        for (int i = 0; i < branch.knots.Count; i++)
        {
            float t = (float)i / Mathf.Max(1, branch.knots.Count - 1);

            // 根元(t=0)ほど太くなり、先端(t=1)は細さをキープ
            float rate = Mathf.Lerp(branchThickeningMultiplier, 1.02f, t);
            float targetMax = Mathf.Lerp(0.022f * depthDamp, 0.004f, t);

            branch.knots[i].radius = Mathf.Min(targetMax, branch.knots[i].radius * Mathf.Lerp(1f, rate, depthDamp));
        }

        // 子枝へ再帰
        foreach (var child in branch.childBranches)
        {
            ThickenBranchRecursive(child, depth + 1);
        }
    }

    // ==========================================
    // 伸長・新規分枝生成ヘルパー
    // ==========================================

    /// <summary>
    /// 既存の枝の先端ノードを前進伸長させます。節間間隔を超えた場合は新しい制御点を自動追加します。
    /// </summary>
    private void ExtendBranch(BonsaiSplineDeformer.SplineBranch branch, float deltaLength)
    {
        if (branch == null || branch.knots.Count < 2) return;

        int last = branch.knots.Count - 1;
        Vector3 dir = (branch.knots[last].localPosition - branch.knots[last - 1].localPosition).normalized;
        float currentDist = Vector3.Distance(branch.knots[last].localPosition, branch.knots[last - 1].localPosition);

        // 次の節間距離を超える場合は新ノードを追加
        if (currentDist + deltaLength > branchKnotSpacing)
        {
            float fill = branchKnotSpacing - currentDist;
            branch.knots[last].localPosition += dir * fill;

            float overflow = deltaLength - fill;
            Vector3 newPos = branch.knots[last].localPosition + (dir * overflow);
            float newRadius = Mathf.Max(MinBranchRadius, branch.knots[last].radius * BranchTaperRatio);
            branch.knots.Add(new BonsaiSplineDeformer.SplineKnot(newPos, newRadius));
        }
        else
        {
            branch.knots[last].localPosition += dir * deltaLength;
        }
    }

    /// <summary>
    /// 指定された長さ・方向・太さに基づき、適切な間隔で制御点を配置した新しい枝インスタンスを生成します。
    /// </summary>
    private BonsaiSplineDeformer.SplineBranch CreateBranchByLength(
        string name,
        float attachT,
        Vector3 growthDir,
        float length,
        float baseRadius,
        float spacing)
    {
        growthDir = (growthDir.sqrMagnitude < 0.001f) ? Vector3.forward : growthDir.normalized;
        var b = new BonsaiSplineDeformer.SplineBranch(name, attachT);

        // ノード0(親との接合点)
        b.knots.Add(new BonsaiSplineDeformer.SplineKnot(Vector3.zero, baseRadius));

        float currentDist = spacing;
        float curRadius = baseRadius;

        // 中間制御点の配置(先端に向けて先細り)
        while (currentDist < length - (spacing * 0.35f))
        {
            curRadius = Mathf.Max(MinBranchRadius, curRadius * BranchTaperRatio);
            b.knots.Add(new BonsaiSplineDeformer.SplineKnot(growthDir * currentDist, curRadius));
            currentDist += spacing;
        }

        // 先端制御点の配置
        curRadius = Mathf.Max(MinBranchRadius, curRadius * BranchTaperRatio);
        b.knots.Add(new BonsaiSplineDeformer.SplineKnot(growthDir * length, curRadius));
        return b;
    }

初期状態
スクリーンショット 2026-11-13 114432.png

2回成長
スクリーンショット 2026-11-13 114912.png

8回成長
スクリーンショット 2026-11-13 121914.png

葉の追加

今回は葉のオブジェクトを枝の制御点の位置に配置します。
画像のように葉のオブジェクトを配置した Prefab を用意し、葉を与える位置を盆栽側にコードで与えます。
スクリーンショット 2026-11-13 122351.png

8回成長させた盆栽で試してみます。
スクリーンショット 2026-11-13 122834.png

葉があるだけで雰囲気が出てきましたね。

盆栽手入れ機能の実装

UIボタンでの手入れ切り替え

作成する順序としてはこのUIは後のほうでしたが、今後のイメージがわかりやすいように先に記述させていただきます。

UIボタンを右手人差し指で触れることによりお手入れモードが切り替わります。

方法としては、人差し指の位置とUIにColliderを配置して、接触を検知して関数を呼び出しています。
左手のひらを自身へ向けることで左手の平に配置されたUIが Active で表示させています。

当初は画面上に常に表示させていましたが、自身とUIの距離を調整できないとピントが合わせられないと気付いたため、距離調節がしやすい左手の平に配置。

「ShowUiOnWrist」コードを表示


using UnityEngine;

public class ShowUiOnWrist : MonoBehaviour
{
    [Header("参照")]
    [SerializeField] private Transform headCamera;
    [SerializeField] private Transform handTransform;   // 手首(Wrist)のTransform
    [SerializeField] private GameObject menuCanvas;     // 表示するcanvas

    [Header("手のひらの向き設定")]
    [Tooltip("手のひらが向いているローカル軸(モデルに合わせて選択)")]
    [SerializeField] private Vector3 palmLocalNormal = Vector3.forward;

    [Header("判定しきい値")]
    [Range(0f, 1f)]
    [SerializeField] private float showThreshold = 0.7f;  // 約45度以内で表示
    [Range(0f, 1f)]
    [SerializeField] private float hideThreshold = 0.5f;  // 約60度以上離れたら非表示

    private bool isMenuVisible = false;

    void Start()
    {
        if (headCamera == null && Camera.main != null)
        {
            headCamera = Camera.main.transform;
        }

        if (menuCanvas != null)
        {
            menuCanvas.SetActive(false);
        }
    }

    void Update()
    {
        if (handTransform == null || headCamera == null || menuCanvas == null) return;

        // 手のひらのワールド法線ベクトルを算出
        Vector3 palmWorldNormal = handTransform.TransformDirection(palmLocalNormal);

        // 手から頭(カメラ)へ向かうベクトル
        Vector3 toHead = (headCamera.position - handTransform.position).normalized;

        // 内積(1.0 = 完全一致、0.0 = 90度、-1.0 = 真逆)
        float dot = Vector3.Dot(palmWorldNormal, toHead);

        // チャタリングを防止
        if (!isMenuVisible && dot > showThreshold)
        {
            isMenuVisible = true;
            menuCanvas.SetActive(true);
        }
        else if (isMenuVisible && dot < hideThreshold)
        {
            isMenuVisible = false;
            menuCanvas.SetActive(false);
        }
    }
}

水やり機能

今回は水と盆栽が接触することで水やりの判定とします。
水とするオブジェクトに以下のコンポーネントを付けます。
Rigidbody , Collider ,任意のTag(_WaterDrop)

鉢側には水と接触したか、水やりが既に行われたか、判定するコードを与えます。
また、土の部分に Collider と RigidBody コンポーネントを追加します。

「BonsaiWaterReceiveLogic」コードを表示

   [SerializeField] private string waterDropTag = "_WaterDrop";
   [SerializeField] private int requiredWaterCount = 5; // 1回の水やりに必要な水滴数

   private int currentWaterCount = 0;
   private bool isWateringCompleted = false;

   // 水やり完了イベント
   public event Action OnWateringCompleted;

   private void OnTriggerEnter(Collider other)
   {
       if (isWateringCompleted) return;

       // 生成された WaterDrop との衝突判定
       if (other.CompareTag(waterDropTag))
       {
           currentWaterCount++;
           Destroy(other.gameObject); // 接触した水滴オブジェクトを破棄

           if (currentWaterCount >= requiredWaterCount)
           {
               isWateringCompleted = true;
               OnWateringCompleted?.Invoke();
           }
       }
   }
    public void ResetWaterState()
    {
        currentWaterCount = 0;
        isWateringCompleted = false;
    }
   

次にジョウロのオブジェクトに対して一定の角度まで傾けると水が出る機能を与えます。

コードを表示


    [SerializeField] private float lowerFloat = -0.7f;
    [SerializeField] Transform nozzleTransform;
    [SerializeField] GameObject waterDropPrefab;
    [SerializeField] float intervalFrames = 30;

    private float destroyTime = 3f;
    private float frameCounter;
    private void Update()
    {
        float upDot = Vector3.Dot(transform.up, Vector3.up);

        if (upDot <= lowerFloat)
        {
            OnFacingDown();
        }
    }

    private void OnFacingDown()
    {
        //一定フレームごとに処理する
        frameCounter++;
        if (frameCounter < intervalFrames)
        {
            return;
        }
        // フレームに達したらカウンターをリセット
        frameCounter = 0;

        //水滴オブジェクトの生成
        GameObject waterDrop = Instantiate(waterDropPrefab, nozzleTransform);
        Rigidbody rb = waterDrop.GetComponent<Rigidbody>();

        if (rb != null)
        {
            rb.useGravity = true;

        }
        Destroy(waterDrop, destroyTime);
    }

これでジョウロを傾けて盆栽に水を与える機能ができました。

次にジョウロを手に持たせたいと思います。
持たせたい手の見た目の位置 OVRHandVisualRight→RightHandの子に JoroPositionを用意し画像のように手のひらの位置にジョウロを配置します。
これによって手の位置に自動的に追従されるようになります。
スクリーンショット 2026-11-12 171724.png

ジョウロを使うときのみ オブジェクトを Active にするため配置ができたら Active を false にしておきましょう。

今後、ほかのお手入れ道具も手の位置に忍ばせておき、UIによるお手入れモード(水やりモード、カットモード、etc)を選択されたとき表示するようにします。

盆栽の曲げ機能の作成

盆栽の形状を構成する制御点をハンドトラッキングによって動かせるようにします。
今回は人差し指の距離と最も近い制御点に対して操作するようにします。

今回は以下のコードで指の位置参照しています。

「HandPositionChecker」コードを表示


using UnityEngine;

public class HandPositionChecker : MonoBehaviour
{
    public enum HandType { Right, Left }

    [Header("手の設定")]
    [SerializeField] private HandType handType = HandType.Right;
    [SerializeField] private OVRSkeleton ovrSkeleton;

    private Transform thumbTipBone;
    private Transform indexTipBone;
    private Transform middleTipBone;
    private Transform ringTipBone;
    private Transform littleTipBone;


    //[SerializeField] private GameObject posSp;

    //外部参照用プロパティ
    public HandType CurrentHandType => handType;
    public bool IsInitialized => indexTipBone != null;
    public Vector3 ThumbPosition => thumbTipBone != null ? thumbTipBone.position : Vector3.zero;
    public Vector3 IndexPosition => indexTipBone != null ? indexTipBone.position : Vector3.zero;
    public Vector3 MiddlePosition => middleTipBone != null ? middleTipBone.position : Vector3.zero;
    public Vector3 RingPosition => ringTipBone != null ? ringTipBone.position : Vector3.zero;
    public Vector3 LittlePosition => littleTipBone != null ? littleTipBone.position : Vector3.zero;

    private void Update()
    {
        //スケルトン初期化完了後に全指先端ボーン(OpenXR規格)を特定・キャッシュ
        if (indexTipBone == null)
        {
            if (ovrSkeleton != null && ovrSkeleton.IsInitialized)
            {
                foreach (var bone in ovrSkeleton.Bones)
                {
                    if (bone.Transform == null) continue;

                    if (bone.Id == OVRSkeleton.BoneId.XRHand_ThumbTip) thumbTipBone = bone.Transform;
                    else if (bone.Id == OVRSkeleton.BoneId.XRHand_IndexTip) indexTipBone = bone.Transform;
                    else if (bone.Id == OVRSkeleton.BoneId.XRHand_MiddleTip) middleTipBone = bone.Transform;
                    else if (bone.Id == OVRSkeleton.BoneId.XRHand_RingTip) ringTipBone = bone.Transform;
                    else if (bone.Id == OVRSkeleton.BoneId.XRHand_LittleTip) littleTipBone = bone.Transform;
                }

                if (indexTipBone == null)
                {
                    FindOpenXRBonesByName();
                }

                if (indexTipBone != null)
                {
                    Debug.Log($"<color=cyan>[{handType}手]</color> 全指先端ボーン(OpenXR Skeleton)を認識しました。");
                }
            }
            return;
        }
    }

    private void FindOpenXRBonesByName()
    {
        foreach (var bone in ovrSkeleton.Bones)
        {
            if (bone.Transform == null) continue;
            string name = bone.Transform.name;

            if (name.Contains("XRHand_ThumbTip")) thumbTipBone = bone.Transform;
            else if (name.Contains("XRHand_IndexTip")) indexTipBone = bone.Transform;
            else if (name.Contains("XRHand_MiddleTip")) middleTipBone = bone.Transform;
            else if (name.Contains("XRHand_RingTip")) ringTipBone = bone.Transform;
            else if (name.Contains("XRHand_LittleTip") || name.Contains("XRHand_PinkyTip")) littleTipBone = bone.Transform;
        }
    }

}

Empty Object にスクリプトをアタッチ。
image.png
R_HandPosCheck L_HandPosCheck オブジェクトを用いて今後、手の10本の指先それぞれを参照できるようにしています。

補足
指の先に追従する OVRHandVisual →XRHand_IndexTip の位置を参照するのでもよかったかと後から思いました。
スクリーンショット 2026-11-13 125605.png

前回の記事の「ジェスチャー判定の実装」で作成した"つまむ"を行っているときに、もっとも近い制御点を動かせるようにしていきます。

"つまみ"を行うと InputMgr でつまんでいる状態とさせます。
スクリーンショット 2026-11-13 135447.png

InputMgr から"つまみ"の状態を得て、曲げ機能のスクリプトで制御点を動かします。

曲げ機能「BonsaiBendLogic」の主要コードを表示


/// <summary>
    /// 制御点を初期位置から動かせる最大許容距離 (m)。
    /// 枝がありえない角度に折れ曲がったり、極端に変形するのを防止します。
    /// </summary>
    private const float MaxBendDistance = 0.04f; // 4cm制限

    // ==========================================
    // 掴み(変形・曲げ)操作
    // ==========================================

    /// <summary>
    /// 制御点の掴みを開始します。
    /// 初期位置と直前ノードからの伸長方向ベクトルを記憶し、以後の垂直拘束の基準とします。
    /// </summary>
    private void StartGrab()
    {
        isGrabbing = true;
        grabbedTarget = currentHoverTarget;
        grabOffset = grabbedTarget.worldPos - CurrentPinchPosition;

        // 基準姿勢と伸長方向(直前ノードからの向き)を記録
        if (grabbedTarget.isTrunk)
        {
            grabbedInitialLocalPos = bonsaiTree.TrunkKnots[grabbedTarget.knotIndex].localPosition;
            Vector3 prevPos = bonsaiTree.TrunkKnots[grabbedTarget.knotIndex - 1].localPosition;
            Vector3 diff = grabbedInitialLocalPos - prevPos;
            grabbedGrowthDir = diff.sqrMagnitude > 1e-6f ? diff.normalized : Vector3.up;
        }
        else if (grabbedTarget.branch != null)
        {
            grabbedInitialLocalPos = grabbedTarget.branch.knots[grabbedTarget.knotIndex].localPosition;
            Vector3 prevPos = grabbedTarget.branch.knots[grabbedTarget.knotIndex - 1].localPosition;
            Vector3 diff = grabbedInitialLocalPos - prevPos;
            grabbedGrowthDir = diff.sqrMagnitude > 1e-6f ? diff.normalized : Vector3.forward;
        }

        ClearHoverHighlight();
    }

    /// <summary>
    /// ピンチ移動に合わせて制御点を変形します。
    /// 枝の伸びる方向に対する垂直平面への投影と、定数距離制限を適用して座標を決定します。
    /// </summary>
    private void UpdateGrabbedKnot()
    {
        Transform treeTr = bonsaiTree.transform;
        Vector3 targetWorldPos = CurrentPinchPosition + grabOffset;

        // 手の移動先から非拘束の目標ローカル座標を算出
        Vector3 rawTargetLocalPos;
        if (grabbedTarget.isTrunk)
        {
            rawTargetLocalPos = treeTr.InverseTransformPoint(targetWorldPos);
        }
        else
        {
            Vector3 localInTree = treeTr.InverseTransformPoint(targetWorldPos);
            rawTargetLocalPos = Quaternion.Inverse(grabbedTarget.attachLocalRot) * (localInTree - grabbedTarget.attachLocalPos);
        }

        // 初期位置からの変位ベクトルを計算
        Vector3 delta = rawTargetLocalPos - grabbedInitialLocalPos;

        // 枝の伸長方向に対して垂直な平面上へ投影(枝の伸縮を遮断)
        Vector3 perpendicularDelta = Vector3.ProjectOnPlane(delta, grabbedGrowthDir);

        // 最大移動距離(MaxBendDistance)内にクランプ
        Vector3 constrainedDelta = Vector3.ClampMagnitude(perpendicularDelta, MaxBendDistance);

        // 拘束適用後の最終ローカル座標
        Vector3 finalLocalPos = grabbedInitialLocalPos + constrainedDelta;

        //  ノード座標の適用
        if (grabbedTarget.isTrunk)
        {
            bonsaiTree.TrunkKnots[grabbedTarget.knotIndex].localPosition = finalLocalPos;
        }
        else if (grabbedTarget.branch != null)
        {
            grabbedTarget.branch.knots[grabbedTarget.knotIndex].localPosition = finalLocalPos;
        }

        // メッシュとマーカーの再生成
        bonsaiTree.RebuildMesh();
    }

プレイヤーが"曲げ"モードの時のみ制御点を動かせるようにできたら実装完了です。

*曲げを行う様子のGIF

剪定(カット)の実装

盆栽の枝や葉のカットをハンドトラッキングで行います。

ハサミのオブジェクトを指先に配置します。
XRHand_IndexTip の子に CutterOnIndex を配置、そのもとでハサミオブジェクト Scissorsを配置します。
スクリーンショット 2026-11-13 141317.png

ハサミオブジェクトの位置を調整します。
親指と人差し指で挟むように持たせたいですが、ハサミの形状を調整する必要があったりと技術的に難しいため、人差し指の先に配置します。

スクリーンショット 2026-11-13 141015.png

指をつまむ動きをすると対象の葉や枝を削除できるようにしていきます。

"曲げ"と違って、制御点だけでなく、大量の葉も作用する対象となるため、ハンドジェスチャーの動作中に"最も近い"対象がずれてしまう恐れがあります。
そのため”カット”では左人差し指と最も近い枝や葉を選択対象とします。

"カット"を行う操作手順について

  • 左人差し指で"カット"対象を選ぶ(指を近づける)
  • 右手で親指と人差し指をつまむような動作をする

"カット"するスクリプトを作成します。

"カット"機能「BonsaiScissorsCutter」の主要コードを表示


/// <summary>
    /// 毎フレーム左手の先端位置を追跡し、最寄りの切断候補の探索とハイライトの切り替えを行います。
    /// </summary>
    private void Update()
    {
        // トラッキング未完了、または盆栽参照がない場合はターゲットを解除して早期リターン
        if (bonsaiTree == null || targetHandChecker == null || !targetHandChecker.IsInitialized)
        {
            SetTarget(default);
            return;
        }

        // 1. 左手人差し指座標(IndexPosition)で最寄りのカット候補を探索
        CutTarget newTarget = EvaluateCutTarget(targetHandChecker.IndexPosition);

        // 2. ターゲットを更新(変更があった場合のみハイライトの付け替えが発生)
        SetTarget(newTarget);
    }

    /// <summary>
    /// 右手ジェスチャー(OnCutTriggered)検知時に呼び出され、切断処理を行います。
    /// </summary>
    private void HandleCutTriggered()
    {
        if (!currentTarget.isValid) return;

        ExecuteCut();

    }

    /// <summary>
    /// 指先座標周辺の空間を探索し、最短距離にある切断対象(枝または葉)を抽出します。
    /// ※主幹(Trunk)は仕様によりカット対象から除外しています。
    /// </summary>
    /// <param name="targetFingerPos">左手人差し指先端のワールド座標</param>
    /// <returns>検出された最寄りの CutTarget(範囲外なら default)</returns>
    private CutTarget EvaluateCutTarget(Vector3 targetFingerPos)
    {
        CutTarget foundTarget = default;
        float minDistance = cutSnappingRadius;
        Transform treeTr = bonsaiTree.transform;

        // 1. 枝(Branch)の探索
        if (bonsaiTree.TrunkKnots.Count >= 2 && bonsaiTree.RootBranches != null)
        {
            List<SplineFrameTemp> trunkFrames = EvaluateSpline(bonsaiTree.TrunkKnots, 12, null);
            foreach (var branch in bonsaiTree.RootBranches)
            {
                SearchBranchCutRecursive(branch, trunkFrames, treeTr, targetFingerPos, ref minDistance, ref foundTarget);
            }
        }

        // 2. 葉(Leaf)の探索
        if (bonsaiTree.ActiveLeafItems != null)
        {
            for (int i = 0; i < bonsaiTree.ActiveLeafItems.Count; i++)
            {
                var leaf = bonsaiTree.ActiveLeafItems[i];
                if (leaf == null || leaf.IsCut) continue;

                float dist = leaf.GetDistanceToBlade(targetFingerPos);

                if (dist < leafCutRadius && dist < minDistance)
                {
                    minDistance = dist;
                    foundTarget = new CutTarget
                    {
                        isValid = true,
                        isLeaf = true,
                        leafItem = leaf,
                        branch = null,
                        knotIndex = -1,
                        worldPos = leaf.transform.position
                    };
                }
            }
        }

        return foundTarget;
    }

    /// <summary>
    /// 確定したターゲットの切断を実行します。
    /// 切断直前にハイライトを安全に解除することで、オブジェクトプールやメッシュ再生成時の色残りを防止します。
    /// </summary>
    private void ExecuteCut()
    {
        CutTarget targetToCut = currentTarget;

        // 切断直前にターゲット参照とハイライトを解除
        SetTarget(default);

        if (targetToCut.isLeaf)
        {
            if (targetToCut.leafItem != null)
            {
                bonsaiTree.CutLeaf(targetToCut.leafItem);
            }
        }
        else
        {
            if (targetToCut.branch != null)
            {
                bonsaiTree.CutBranch(targetToCut.branch, targetToCut.knotIndex);
            }
        }
    }

実装して"カット"する様子

プレイヤーお手入れモードの切り替え

プレイヤーの選択したお手入れのモードに合わせて出現する道具、"水やり"ならジョウロ、"カット"ならハサミ、をアクティブ(表示)にし、それぞれのお手入れ用の機能をもつスクリプトもアクティブにします。

「InputManager」の全体コードを表示


using System;
using System.Collections.Generic;
using UnityEngine;
using Oculus.Interaction;
using Oculus.Interaction.HandGrab; 

/// <summary>
/// 盆栽のお手入れ作業モード。
/// </summary>
public enum BonsaiCareMode
{
    None,      // 待機・観察(鉢の掴み・移動が可能)
    Bend,      // 枝・幹の曲げ(Grabbable無効、BendPoint表示)
    Cut,       // 剪定(ハサミ有効、Grabbable無効)
    Watering  // 水やり(ジョウロ有効、Grabbable無効)
}

/// <summary>
/// プレイヤーの手入れモード(待機・曲げ・剪定・水やり)の切り替えと、
/// それに伴うツールオブジェクトの有効化、Grabbable(鉢の掴み)の排他制御、
/// およびジェスチャー入力イベントを中継・統括するマネージャー。
/// </summary>
public class InputManager : MonoBehaviour
{
    // ==========================================
    // インスペクター設定項目
    // ==========================================

    [Header("盆栽本体参照")]
    [SerializeField, Tooltip("操作対象の盆栽本体コンポーネント")]
    private BonsaiSplineDeformer bonsaiTree;

    [Header("現在のお手入れモード")]
    [SerializeField, Tooltip("起動時の初期モード")]
    private BonsaiCareMode currentMode = BonsaiCareMode.None;

    [Header("モード連動オブジェクト・スクリプト")]
    [SerializeField, Tooltip("剪定モード時に表示するハサミの3Dモデル")]
    private GameObject scissorObj;

    [SerializeField, Tooltip("水やりモード時に表示するジョウロの3Dモデル")]
    private GameObject wateringCanObj;

    [SerializeField, Tooltip("曲げモード時に有効化するコントローラー")]
    private BonsaiBendLogic bendLogic;

    [SerializeField, Tooltip("剪定モード時に有効化するハサミ切断コントローラー")]
    private BonsaiScissorsCutter scissorsCutter;

    [Header("排他制御する Grabbable オブジェクト群")]
    [SerializeField, Tooltip("モード中につまみ操作と競合しないよう無効化する鉢などのGrabbableリスト")]
    private List<Grabbable> targetGrabbables = new List<Grabbable>();

    // ==========================================
    // イベント定義
    // ==========================================

    /// <summary>ピンチジェスチャー(人差し指と親指を閉じた)開始イベント</summary>
    public event Action OnPinchStarted;

    /// <summary>ピンチジェスチャー(指を開いた)終了イベント</summary>
    public event Action OnPinchEnded;

    /// <summary>ハサミ切断ジェスチャー(トリガー操作など)開始イベント</summary>
    public event Action OnCutTriggered;

    /// <summary>ハサミ切断ジェスチャー終了イベント</summary>
    public event Action OnCutReleased;

    /// <summary>お手入れモードが変更された際に発行される通知イベント</summary>
    public event Action<BonsaiCareMode> OnCareModeChanged;

    // ==========================================
    // 公開プロパティ
    // ==========================================

    /// <summary>現在アクティブなお手入れモード</summary>
    public BonsaiCareMode CurrentMode => currentMode;

    /// <summary>現在ピンチジェスチャー中か</summary>
    public bool IsPinching { get; private set; }

    /// <summary>現在カットジェスチャー入力中か</summary>
    public bool IsCutInteracting { get; private set; }

    // ==========================================
    // Unity ライフサイクル
    // ==========================================

    /// <summary>
    /// 開始処理。インスペクターで指定された初期モードに合わせてコンポーネント状態を適用します。
    /// </summary>
    private void Start()
    {
        ApplyModeComponents(currentMode);
    }

    // ==========================================
    // モード切り替え制御
    // ==========================================

    /// <summary>
    /// お手入れモードを切り替えます。
    /// 切り替え直前に進行中のピンチや切断入力を強制終了し、各ツールの表示・排他制御を更新します。
    /// </summary>
    /// <param name="newMode">遷移先の新しいモード</param>
    public void SetCareMode(BonsaiCareMode newMode)
    {
        if (currentMode == newMode) return;

        // モード変更前に進行中の入力操作を安全に解除
        if (IsPinching) NotifyPinchEnd();
        if (IsCutInteracting) NotifyIndexCutExit();

        currentMode = newMode;
        ApplyModeComponents(currentMode);

        OnCareModeChanged?.Invoke(currentMode);
        Debug.Log($"<color=cyan>[InputManager]</color> お手入れモードを切り替えました: {currentMode}");
    }

    /// <summary>
    /// 指定されたモードに応じて、各ツールの表示/非表示、スクリプトの有効化、
    /// およびGrabbableの排他制御を一括適用します。
    /// </summary>
    /// <param name="mode">適用するお手入れモード</param>
    private void ApplyModeComponents(BonsaiCareMode mode)
    {
        // 1. 曲げコントローラーの有効化
        if (bendLogic != null)
        {
            bendLogic.enabled = (mode == BonsaiCareMode.Bend);
        }

        // 2. 制御点マーカー(BendPoint)の表示制御(BendまたはCutモード時に表示)
        if (bonsaiTree != null)
        {
            bonsaiTree.SetBendPointsVisible(mode == BonsaiCareMode.Bend || mode == BonsaiCareMode.Cut);
        }

        // 3. 剪定ツール(ハサミモデル & カッターロジック)の有効化
        if (scissorObj != null)
        {
            scissorObj.SetActive(mode == BonsaiCareMode.Cut);
        }

        if (scissorsCutter != null)
        {
            scissorsCutter.enabled = (mode == BonsaiCareMode.Cut);
        }

        // 4. 水やりツール(ジョウロモデル)の有効化
        if (wateringCanObj != null)
        {
            wateringCanObj.SetActive(mode == BonsaiCareMode.Watering);
        }

        // 5. Grabbable 排他制御(待機・観察のNoneモード時のみ鉢の掴みを許可)
        bool allowGrabbing = (mode == BonsaiCareMode.None);
        SetGrabbableStateAll(allowGrabbing);
    }

    // ==========================================
    // UIボタン用ショートカットAPI
    // ==========================================

    /// <summary>待機・観察モード(None)へ切り替えます。</summary>
    public void SetModeNone() => SetCareMode(BonsaiCareMode.None);

    /// <summary>曲げモード(Bend)へ切り替えます。</summary>
    public void SetModeBend() => SetCareMode(BonsaiCareMode.Bend);

    /// <summary>剪定モード(Cut)へ切り替えます。</summary>
    public void SetModeCut() => SetCareMode(BonsaiCareMode.Cut);

    /// <summary>水やりモード(Watering)へ切り替えます。</summary>
    public void SetModeWatering() => SetCareMode(BonsaiCareMode.Watering);

    // ==========================================
    // Grabbable 排他制御ロジック
    // ==========================================

    /// <summary>
    /// 登録されている全ての Grabbable およびその子要素の Interactable の有効/無効を一括切り替えます。
    /// </summary>
    /// <param name="state">trueで掴み可能、falseで掴み不可</param>
    private void SetGrabbableStateAll(bool state)
    {
        for (int i = 0; i < targetGrabbables.Count; i++)
        {
            SetSingleGrabbableState(targetGrabbables[i], state);
        }
    }

    /// <summary>
    /// 単一の Grabbable と、その配下にある全種類の Interactable コンポーネント群をまとめて切り替えます。
    /// </summary>
    /// <param name="grabbable">対象のGrabbableコンポーネント</param>
    /// <param name="state">有効化フラグ</param>
    private void SetSingleGrabbableState(Grabbable grabbable, bool state)
    {
        if (grabbable == null) return;

        // Grabbable 本体の切り替え
        grabbable.enabled = state;

        // 近接掴み(GrabInteractable)
        foreach (var interactable in grabbable.GetComponentsInChildren<GrabInteractable>(true))
        {
            interactable.enabled = state;
        }

        // ハンドトラッキング用(HandGrabInteractable)
        foreach (var handInteractable in grabbable.GetComponentsInChildren<HandGrabInteractable>(true))
        {
            handInteractable.enabled = state;
        }

        // 遠隔掴み(DistanceHandGrabInteractable)
        foreach (var distInteractable in grabbable.GetComponentsInChildren<DistanceHandGrabInteractable>(true))
        {
            distInteractable.enabled = state;
        }
    }

    /// <summary>
    /// 動的生成時や手動登録漏れ時に、排他制御対象の Grabbable を動的追加します。
    /// </summary>
    /// <param name="grabbable">登録するGrabbableコンポーネント</param>
    public void RegisterGrabbable(Grabbable grabbable)
    {
        if (grabbable != null && !targetGrabbables.Contains(grabbable))
        {
            targetGrabbables.Add(grabbable);
            SetSingleGrabbableState(grabbable, currentMode == BonsaiCareMode.None);
        }
    }

    // ==========================================
    // 外部入力通知API(HandPositionCheckerやGestureDetectorから呼び出し)
    // ==========================================

    /// <summary>
    /// ピンチジェスチャーの開始を通知し、イベントを発行します。
    /// </summary>
    public void NotifyPinchStart()
    {
        if (IsPinching) return;
        IsPinching = true;
        OnPinchStarted?.Invoke();
    }

    /// <summary>
    /// ピンチジェスチャーの終了を通知し、イベントを発行します。
    /// </summary>
    public void NotifyPinchEnd()
    {
        if (!IsPinching) return;
        IsPinching = false;
        OnPinchEnded?.Invoke();
    }

    /// <summary>
    /// カットジェスチャー時に呼び出し、"カット"トリガーイベントを発行します。
    /// ※Cutモード時のみ有効です。
    /// </summary>
    public void NotifyIndexCutEnter()
    {
        if (currentMode != BonsaiCareMode.Cut) return;

        IsCutInteracting = true;
        OnCutTriggered?.Invoke();
        Debug.Log("<color=red>[InputManager]</color> カット入力検知 (Enter)");
    }

    /// <summary>
    /// カットジェスチャー終了時に呼び出し、"カット"解除イベントを発行します。
    /// </summary>
    public void NotifyIndexCutExit()
    {
        if (!IsCutInteracting) return;

        IsCutInteracting = false;
        OnCutReleased?.Invoke();
        Debug.Log("<color=orange>[InputManager]</color> カット入力解除 (Exit)");
    }
}

スクリプトを Scene内任意の Managerオブジェクト に与えて、制御するオブジェクトとスクリプトをアタッチします。
スクリーンショット 2026-11-13 144055.png

データ保存

育てた盆栽の状態をセーブしてロードできるようにします。
立体データをそのまま保存するとデータ量が膨大になるため、「幹や枝の曲がり具合(点の位置と太さ)」と「切り落とした葉の記録」をテキストJsonにします。

盆栽の枝は、「幹から主枝が生え、そこから小枝が生え、さらに孫枝...」とツリーの構造になっているため、セーブする際は一列のリストへ整理してから保存します。

これにより、成長とともにどのように変形、葉や枝をカットしてもそのままの状態をセーブしてロードできます。

「BonsaiSaveDataManager」コードを表示


using System;
using System.Collections.Generic;
using System.IO;
using UnityEngine;


// ==========================================
// セーブデータ構造(将来のUI表示用メタデータ内包)
// ==========================================

[Serializable]
public class FlatBranchData
{
    public int id;
    public int parentBranchId; // -1: 主幹に接続、0以上: 親枝のID
    public string name;
    public float attachT;
    public List<BonsaiSplineDeformer.SplineKnot> knots = new List<BonsaiSplineDeformer.SplineKnot>();
}

[Serializable]
public class BonsaiSaveData
{
    [Header("UI表示用メタデータ(将来拡張用)")]
    public int slotId = 0;
    public string saveTimestamp;
    public int currentDay = 1;
    public int totalBranchCount;

    [Header("盆栽実データ")]
    public List<BonsaiSplineDeformer.SplineKnot> trunkKnots = new List<BonsaiSplineDeformer.SplineKnot>();
    public List<FlatBranchData> flatBranches = new List<FlatBranchData>();
    public List<string> cutLeafIds = new List<string>();
}

// ==========================================
// セーブ&ロード マネージャー
// ==========================================

public class BonsaiSaveDataManager : MonoBehaviour
{
    /// <summary>
    /// MianSceneへ推移した際のデータロード
    /// </summary>
    [SerializeField] private GameSessionContext sessionContext;

    private void Start()
    {
        if (sessionContext.mode == GameSessionContext.StartMode.Load)
        {
            activeSlot = sessionContext.targetSlot;
            LoadSavedBonsai();
            Debug.Log(activeSlot + "データでSceneを開始");
        }
        else
        {
            Debug.Log($"データロードなしでsceneを開始");
        }
    }




    [Header("連携対象コンポーネント")]
    [SerializeField] private BonsaiSplineDeformer bonsaiDeformer;
    [SerializeField] private BonsaiCycleManager cycleManager; // 日付連携(未設定でも動作)

    [Header("スロット設定(現在は 0 固定)")]
    [SerializeField] private int activeSlot = 0;

    /// <summary>
    /// スロットIDごとの保存先パスを取得
    /// </summary>
    private string GetSaveFilePath(int slot)
    {
        return Path.Combine(Application.persistentDataPath, $"bonsai_save_slot_{slot}.json");
    }

    // ==========================================
    // UIボタンから直接呼び出すAPI
    // ==========================================

    /// <summary>
    /// プレイヤーが「セーブ」ボタンを押した際に実行
    /// </summary>
    public void SaveCurrentBonsai()
    {
        if (bonsaiDeformer == null)
        {
            Debug.LogError("[SaveManager] BonsaiDeformer がセットされていません。");
            return;
        }

        var flatBranches = FlattenBranchHierarchy(bonsaiDeformer.RootBranches);

        BonsaiSaveData data = new BonsaiSaveData
        {
            slotId = activeSlot,
            saveTimestamp = DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"),
            currentDay = cycleManager != null ? cycleManager.CurrentDay : 1,
            totalBranchCount = flatBranches.Count,
            trunkKnots = new List<BonsaiSplineDeformer.SplineKnot>(bonsaiDeformer.TrunkKnots),
            flatBranches = flatBranches,
            cutLeafIds = new List<string>(bonsaiDeformer.CutLeafIds)
        };

        string json = JsonUtility.ToJson(data, true);
        string filePath = GetSaveFilePath(activeSlot);

        try
        {
            File.WriteAllText(filePath, json);
            Debug.Log($"<color=green>[SaveManager]</color> セーブ完了: Slot {activeSlot} ({data.saveTimestamp}) 枝数:{data.totalBranchCount}");
        }
        catch (Exception ex)
        {
            Debug.LogError($"[SaveManager] セーブ書き込み失敗: {ex.Message}");
        }
    }

    /// <summary>
    /// プレイヤーが「ロード」ボタンを押した際に実行
    /// </summary>
    public void LoadSavedBonsai()
    {
        string filePath = GetSaveFilePath(activeSlot);

        if (!File.Exists(filePath))
        {
            Debug.LogWarning($"[SaveManager] セーブデータが存在しません: {filePath}");
            return;
        }

        try
        {
            string json = File.ReadAllText(filePath);
            BonsaiSaveData data = JsonUtility.FromJson<BonsaiSaveData>(json);

            if (data != null && bonsaiDeformer != null)
            {
                // フラットリストから階層ツリー構造へ復元
                List<BonsaiSplineDeformer.SplineBranch> restoredBranches = ReconstructBranchHierarchy(data.flatBranches);

                // 盆栽本体へ適用(メッシュ、マーカー、葉の状態を同期)
                bonsaiDeformer.ApplySaveData(data.trunkKnots, restoredBranches, data.cutLeafIds);

                Debug.Log($"<color=cyan>[SaveManager]</color> ロード完了: 保存日時 {data.saveTimestamp} (Day {data.currentDay})");
            }
        }
        catch (Exception ex)
        {
            Debug.LogError($"[SaveManager] ロード失敗: {ex.Message}");
        }
    }

    /// <summary>
    /// セーブデータが存在するか確認(ロードボタンの活性化判定などに使用)
    /// </summary>
    public bool HasSaveData()
    {
        return File.Exists(GetSaveFilePath(activeSlot));
    }

    /// <summary>
    /// デバッグ用:セーブデータを削除して初期状態に戻す
    /// </summary>
    public void DeleteCurrentSave()
    {
        string filePath = GetSaveFilePath(activeSlot);
        if (File.Exists(filePath))
        {
            File.Delete(filePath);
            Debug.Log($"<color=yellow>[SaveManager]</color> Slot {activeSlot} のデータを削除しました。");
        }
    }

    // ==========================================
    // 内部ロジック:ツリー構造の平坦化 & 復元
    // ==========================================

    private List<FlatBranchData> FlattenBranchHierarchy(List<BonsaiSplineDeformer.SplineBranch> roots)
    {
        List<FlatBranchData> flatList = new List<FlatBranchData>();
        int currentId = 0;

        foreach (var root in roots)
        {
            TraverseFlatten(root, -1, ref currentId, flatList);
        }
        return flatList;
    }

    private void TraverseFlatten(BonsaiSplineDeformer.SplineBranch branch, int parentId, ref int currentId, List<FlatBranchData> list)
    {
        int myId = currentId++;
        FlatBranchData flat = new FlatBranchData
        {
            id = myId,
            parentBranchId = parentId,
            name = branch.name,
            attachT = branch.attachT,
            knots = new List<BonsaiSplineDeformer.SplineKnot>(branch.knots)
        };
        list.Add(flat);

        foreach (var child in branch.childBranches)
        {
            TraverseFlatten(child, myId, ref currentId, list);
        }
    }

    private List<BonsaiSplineDeformer.SplineBranch> ReconstructBranchHierarchy(List<FlatBranchData> flatList)
    {
        List<BonsaiSplineDeformer.SplineBranch> rootList = new List<BonsaiSplineDeformer.SplineBranch>();
        Dictionary<int, BonsaiSplineDeformer.SplineBranch> branchMap = new Dictionary<int, BonsaiSplineDeformer.SplineBranch>();

        foreach (var flat in flatList)
        {
            var branch = new BonsaiSplineDeformer.SplineBranch(flat.name, flat.attachT)
            {
                knots = new List<BonsaiSplineDeformer.SplineKnot>(flat.knots)
            };
            branchMap[flat.id] = branch;
        }

        foreach (var flat in flatList)
        {
            var currentBranch = branchMap[flat.id];
            if (flat.parentBranchId == -1)
            {
                rootList.Add(currentBranch);
            }
            else if (branchMap.TryGetValue(flat.parentBranchId, out var parentBranch))
            {
                parentBranch.childBranches.Add(currentBranch);
            }
        }

        return rootList;
    }
}

空間メッシュ検知

前回の記事で導入した Meta XR SDK All in one に含まれる Meta MR Utility kit では Meta Quest 3のスペース空間のスキャンを行ったデータをもとに部屋のものに合わせてメッシュを貼ることができます。
スクリーンショット 2026-11-13 110820.png

これを用いて、現実の机の位置に接触判定を与えて、机の上にバーチャルである盆栽を置くことができます。

方法としては

  • Empty Object に MRUK コンポーネントを追加
    スクリーンショット 2026-11-13 111527.png

  • Empty Object に EffectMesh コンポーネントを追加
    スクリーンショット 2026-11-13 111446.png
    Colliders にチェックを入れることで空間スキャンしたメッシュにコライダーが与えられます。
    Labels は EffectMesh を与えるものを選択します。
    すべてを対象にするのであれば Everything のままでよいです。

補足
Empty Object にコンポーネントを与える部分に関しては Building Block から MRUK , EffectMesh を導入してもよいです。

後は Meta Quest 3 で空間をスキャンしたのちに、Unity でビルドされたアプリを開けばパススルーと空間メッシュで現実のものと接触する様子が見えるはずです。

EffextMeshBonsai.jpg

注意
動作が上手くいかなかった場合は一度古いデータを消してから再度空間スキャンすることを試してください。
Meta Quest 3 に古い空間スキャンのデータが残っていると、空間とアンカーの認識がうまくいかずに空間スキャンしたデータがいつまでも読み込まれないことがあります。

開発振り返り

今回XRのコンテンツを開発するのは初めての挑戦でした。
最初はXRとして正常に動作させるだけでも苦労しました。調べた通りの手順を踏んでもうまくいかなかったりと、何度も調べては試しての繰り返しです。特に上記の空間メッシュの”注意”で記述したことを私は知らず、うまく動作せずに頭を抱えていました。

最終的にはハンドトラッキングによるお手入れから盆栽の空間へ設置まででき、ひとまずは成果物とすることができ安心しました。

その一方、盆栽という鑑賞も大切な要素でありながら、ビジュアル面に手を込めることができなかったことを反省点と感じています。特に、枝と葉のつながり部分に関しては、葉のオブジェクトを配置しているだけで綺麗につなげることができていないところは、時間があれば調整したかったところでもあります。

今回の開発経験を通して、自身がXRコンテンツの開発に踏み入りやすくなったため、今後の開発での領域を広げることができました。

0

Go to list of users who liked

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme

What you can do with signing up

来源:Qiita · Unity 开发社区 · qiita.com