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

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

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

正文 · AI 翻译

0

最后更新于 2026-11-13发布于 2026-11-13

前言

本文是开发在 XR 空间中进行盆栽养护应用的第 2 回(功能实现篇)。

基于上一次的“环境构建・手势检测篇”的步骤进行实现。尚未阅读的朋友,请先查看第 1 回。

最终实现的功能列表

  • 動的メッシュによる盆栽の形状生成:
    • 使用 Catmull-Rom 样条曲线,从控制点实时生成并变形树干与树枝的三维网格
  • 段階的な成長ロジック
    • 根据生长步骤实现树干增粗以及树枝的分叉与伸长
  • ハンドトラッキングによるお手入れ:
    • 水やり
      • 通过倾斜检测生成水滴对象,并与盆栽接触
    • 曲げ
      • 通过捏合操作移动控制点并变形树枝
    • カット(剪定)
      • 通过指尖位置判定和捏合手势剪断树枝和树叶
  • UIによるお手入れモード切り替え
    • 在左手掌显示的UI上触摸,切换“弯曲、修剪、浇水等”各模式
  • 盆栽データのセーブ・ロード
    • 将树结构的枝数据和剪下的叶子记录以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始终显示在屏幕上,但发现如果不调整自身与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 设置为 false。

今后,我们还将其他护理工具隐藏在手中,并在选择由UI提供的护理模式(浇水模式、修剪模式等)时显示它们。

盆景弯曲功能的创建

通过手部追踪使构成盆景形状的控制点能够移动。
本次将对食指距离最近的控制点进行操作。

本次使用以下代码来引用手指的位置。

显示“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;
        }
    }

}

将脚本附加到空对象上。
image.png
使用R_HandPosCheck和L_HandPosCheck对象,今后可以分别引用手的十个指尖。

补充
后来我想,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)");
    }
}

将脚本附加到场景内任意 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

查看点赞用户列表

0

注册新用户并更便捷地使用 Qiita

  1. 获取符合您需求的文章
  2. 高效回顾有用信息
  3. 支持深色主题

注册后可享受的功能

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