mcpField
H
hz-unity-placement

hz-unity-placement

github.com/meta-quest开发241222 次安装5 周前更新
代码仓库

关于 hz-unity-placement

hz-unity-placement 是指导 Unity 场景对象相对摆放的技能,面向操作 GameObject 的开发者。它要求基于实时世界空间边界框计算位置,以减少重叠并处理旋转、缩放和父子层级差异。

主要功能

  1. 01读取 Renderer 或 Collider 边界
  2. 02使用世界空间边界计算位置
  3. 03按相对方向与间距定位
  4. 04处理父对象与本地坐标差异
  5. 05验证并说明摆放结果

适用场景

  1. 01将物体放在桌面或其他物体上
  2. 02按指定距离在物体旁摆放对象
  3. 03导入模型后相对场景对象定位

MCP 相关

说明也适用于 Unity MCP 服务所提供的场景检查与变换工具。

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/meta-quest/agentic-tools --skill hz-unity-placement

安装后在 Claude Code 中输入/hz-unity-placement确认已加载。

详细介绍

使用包围盒放置 Unity 对象

此技能可确保在相对于其他对象放置、移动或定位 Unity GameObject 时,正确计算包围盒,以避免重叠并确保准确放置。

何时使用此技能

在以下情况下自动使用此技能:

  • 相对于另一个对象放置对象(放在上面、旁边、上方、下方、前面或后面)
  • 将对象移动到其他对象附近的特定位置
  • 根据现有场景对象实例化预制件
  • 根据场景对象定位导入的模型
  • 指定了 GameObject 之间的任何空间关系

自动触发(无需用户请求即可调用)

重要:检测到以下任意模式时,立即主动调用此技能:

放置用语触发条件
  • 用户说 “将 X 放在 Y 上” 或 “把 X 放在 Y 上”
  • 用户说 “将 X 放在 Y 旁边” 或 “把 X 放在 Y 旁边”
  • 用户说 “将 X 放在 Y 的左侧/右侧”
  • 用户说 “将 X 放在 Y 的前面/后面”
  • 用户说 “将 X 放在 Y 的上方/下方”
  • 用户说 “将 X 放在 Y 附近/周围”
  • 用户要求 “将 X 摆放在 Y 上/周围”
  • 用户要求 “将 X 放在 Y 左侧/右侧/前方/后方若干米/单位处”
多对象场景
  • 在修改涉及多个对象及空间关系的任何 GameObject 位置之前
  • 导入模型并将其定位到现有对象附近时
  • 在场景中排列/整理多个对象时
  • 设置具有空间关系的对象层级时
基于距离的放置
  • 用户指定距离:“左侧 2 米”“上方 0.5 个单位”等。
  • 将指定距离与包围盒计算结合起来
  • 示例:“右侧 1 米” = refMax.x + 1.0 + targetExtents.x

示例场景

自动调用技能(立即执行)

示例 1:“把所有对象放在桌子上”

  • 操作:立即调用 unity-placement 技能
  • 原因:要相对于桌子放置多个对象

示例 2:“把杯子放在桌子上”

  • 操作:调用 unity-placement 技能
  • 原因:单个对象的放置关系为“放在……上”

示例 3:“把台灯放在时钟旁边”

  • 操作:调用 unity-placement 技能
  • 原因:“旁边”表示水平空间关系
  • 流程:获取两个对象的边界,使用 refMax.x + targetExtents.x 将台灯放在时钟旁边

示例 4:“把书放在钢笔左侧 0.5 米处”

  • 操作:调用 unity-placement 技能
  • 原因:基于距离的放置,且包含空间关系
  • 流程:获取边界并计算:penMin.x - 0.5 - bookExtents.x
不调用技能(直接操作)

示例 1:“将对象移动到 [5, 10, 3]”

  • 原因:提供了直接坐标,不涉及相对定位

示例 2:“将立方体的位置设为 (0, 0, 0)”

  • 原因:绝对位置,不需要参照对象

核心原则

计算位置前,务必获取包围盒信息。

切勿猜测对象尺寸——始终从运行中的 Editor 的 Renderer 或 Collider 组件中获取实际边界。

操作 Editor

通过 unity-cli 读取和写入场景,而不要编辑 .unity YAML——直接修改文件对正在运行的 Editor 不可见,而且容易修改错误的场景。

unity status --format json          # look for state "ready"

有关连接、--project-path 和 Safe Mode 恢复,请参阅 unity-cli 技能。若使用 Unity MCP 服务,则适用相同步骤——使用其提供的场景检查和变换工具即可。

说明

步骤 1:确定涉及的对象

用户请求放置对象时:

  1. 确定目标对象(要放置/移动的对象)
  2. 确定参照对象(放置目标对象时所依据的对象)
  3. 记录所需的空间关系(上方、旁边等)
步骤 2:获取包围盒信息

对于这两个对象,找到它们并读取其边界:

unity command find_gameobjects --name Cube --format json

bounds 是一个计算得出的属性,而不是序列化字段,因此 get_component_properties 不会返回它——读取 MeshRenderer 时会得到 m_Materials、m_CastShadows 等属性,但完全没有 bounds。读取 bounds 始终需要使用脚本:

// AgentScripts/GetBounds.cs
using System.Text;
using UnityEngine;

public static class GetBounds
{
    public static string Run(string[] names)
    {
        var sb = new StringBuilder();
        foreach (var name in names)
        {
            var go = GameObject.Find(name);
            if (go == null) { sb.AppendLine($"{name}: NOT FOUND"); continue; }

            if (!TryGetBounds(go, out var b, out var src))
            {
                sb.AppendLine($"{name}: NO Renderer or Collider - bounds unknown");
                continue;
            }

            sb.AppendLine($"{name} [{src}] center={b.center} size={b.size} extents={b.extents} min={b.min} max={b.max}");
        }
        return sb.ToString();
    }

    // Root renderer/collider first; otherwise the union of the children's, which is what an
    // imported model needs since its renderers sit on child GameObjects.
    private static bool TryGetBounds(GameObject go, out Bounds bounds, out string source)
    {
        bounds = default;
        source = null;

        var r = go.GetComponent<Renderer>();
        if (r != null) { bounds = r.bounds; source = "Renderer"; return true; }

        var col = go.GetComponent<Collider>();
        if (col != null) { bounds = col.bounds; source = "Collider"; return true; }

        foreach (var cr in go.GetComponentsInChildren<Renderer>())
        {
            if (source == null) { bounds = cr.bounds; source = "child Renderers"; }
            else bounds.Encapsulate(cr.bounds);
        }
        if (source != null) return true;

        foreach (var cc in go.GetComponentsInChildren<Collider>())
        {
            if (source == null) { bounds = cc.bounds; source = "child Colliders"; }
            else bounds.Encapsulate(cc.bounds);
        }
        return source != null;
    }
}
unity command run_script --file AgentScripts/GetBounds.cs --entry GetBounds.Run \
  --args '[["Cube","Table"]]' --format json

--args 会将 JSON 数组展开为位置参数,因此单个 string[] 参数需要使用嵌套数组。'["Cube","Table"]' 会被读取为两个参数,并报错:"expects 1 argument(s) but 2 were provided"。

了解 Unity 中的 bounds 类型:

属性空间是否考虑旋转?适用场景
Renderer.bounds世界空间是(包围旋转后网格的 AABB)在场景中放置对象——首选
Renderer.localBounds局部空间否(忽略旋转)不考虑旋转影响,比较对象的固有尺寸
Collider.bounds世界空间是(包围旋转后碰撞体的 AABB)不存在 Renderer 时放置对象

放置对象时始终优先使用世界空间 bounds(Renderer.bounds 或 Collider.bounds)。这些值已考虑对象的位置、旋转和缩放,无需手动转换。

步骤 3:直接使用世界空间 bounds(或从局部空间转换)

如果使用世界空间 bounds(首选——来自 Renderer.bounds 或 Collider.bounds):

这些值可直接使用:

worldCenter = bounds.center    // already in world space
worldMin = bounds.min          // already in world space
worldMax = bounds.max          // already in world space
size = bounds.size             // world-space AABB dimensions
extents = bounds.extents       // half of size

如果使用局部空间 bounds(来自 Renderer.localBounds 或手动获取的组件数据):

将对象的位置加上去,即可转换为世界空间:

worldCenter = position + localBounds.center
worldMin = worldCenter - (localBounds.size / 2)
worldMax = worldCenter + (localBounds.size / 2)

**关于旋转的说明:**局部空间 bounds 会忽略旋转。如果对象已旋转,使用局部空间 bounds 进行转换时,计算出的 AABB 不会反映实际的世界空间占用范围。请尽可能优先使用世界空间 bounds。

为每个对象存储以下值:

  • worldCenter:[x, y, z]
  • size:[宽度,高度,深度]
  • extents:[宽度/2,高度/2,深度/2]
步骤 4:计算放置位置

根据空间关系,计算目标位置:

放在上方(对象 A 放在对象 B 上方):

targetY = B.worldMax.y + A.extents.y
targetX = B.worldCenter.x
targetZ = B.worldCenter.z
newPosition = [targetX, targetY, targetZ]

放在旁边(对象 A 放在对象 B 旁边,沿 +X 方向):

targetX = B.worldMax.x + A.extents.x
targetY = B.worldCenter.y
targetZ = B.worldCenter.z
newPosition = [targetX, targetY, targetZ]

放在前方(对象 A 放在对象 B 前方,沿 +Z 方向):

targetX = B.worldCenter.x
targetY = B.worldCenter.y
targetZ = B.worldMax.z + A.extents.z
newPosition = [targetX, targetY, targetZ]

放在上方(悬浮在上方,并留有间隙):

gap = 0.5  // or specified distance
targetY = B.worldMax.y + gap + A.extents.y
targetX = B.worldCenter.x
targetZ = B.worldCenter.z
newPosition = [targetX, targetY, targetZ]

放在附近/旁边并留出距离(对象 A 放在对象 B 旁边,并留出指定间隙):

gap = user_specified_distance  // e.g., 0.5 meters
// Right side (+X):
targetX = B.worldMax.x + gap + A.extents.x
// Left side (-X):
targetX = B.worldMin.x - gap - A.extents.x
targetY = B.worldCenter.y  // or B.worldMin.y + A.extents.y for ground level
targetZ = B.worldCenter.z
newPosition = [targetX, targetY, targetZ]

向左/右/前/后移动 X 米/单位:

// "2 meters to the right of B"
targetX = B.worldMax.x + 2.0 + A.extents.x

// "1.5 units to the left of B"
targetX = B.worldMin.x - 1.5 - A.extents.x

// "0.5 meters in front of B"
targetZ = B.worldMax.z + 0.5 + A.extents.z

// "1 unit behind B"
targetZ = B.worldMin.z - 1.0 - A.extents.z
步骤 5:应用位置

set_transform 设置的是 localPosition,而不是世界位置。已验证:当父对象位于世界坐标 x=10 时,对其子对象设置 --position "[1,0,0]" 会得到 localPosition (1,0,0) / position (11,0,0)。以上所有公式计算出的都是世界位置,因此:

  • 无父对象的对象——局部位置与世界位置相同,直接应用计算出的值:

    unity command set_transform --target Cube --position "[0,1.05,2]" --format json
    unity command save_scene --format json
    

    数组参数必须在一个参数中以 JSON 数组形式提供。--position 0 1.05 2 会被拒绝,并返回 INVALID_COMMAND_ARGS。

  • 有父对象的对象——先进行转换,或在脚本中设置 transform.position,该属性按定义使用世界空间:

    // AgentScripts/SetWorldPosition.cs
    using UnityEditor;
    using UnityEditor.SceneManagement;
    using UnityEngine;
    
    public static class SetWorldPosition
    {
        public static string Run(string name, float x, float y, float z)
        {
            var go = GameObject.Find(name);
            if (go == null) return $"ERROR: '{name}' not found.";
    
            Undo.RecordObject(go.transform, "Place object");
            go.transform.position = new Vector3(x, y, z);
            EditorSceneManager.MarkSceneDirty(go.scene);
            return $"{name}: world={go.transform.position} local={go.transform.localPosition}";
        }
    }
    
    unity command run_script --file AgentScripts/SetWorldPosition.cs \
      --entry SetWorldPosition.Run --args '["Cube", 0, 1.05, 2]' --format json
    unity command save_scene --format json
    

该脚本会返回这两个空间中的值,这是确认最终位置的唯一方法——get_component_properties --type Transform 只会公开 m_LocalPosition,因此无法判断该值是世界位置还是局部位置。

步骤 6:验证放置结果

放置完成后,告知用户:

  • 计算出的位置
  • 使用的 bounds
  • 所做的任何调整
  • 建议用户查看 Scene 视图,或在头显上启动后运行 metavr capture screenshot

常见放置模式

放在上方(堆叠)
  • 使用参照对象的顶面(worldMax.y)
  • 加上目标对象的一半高度(extents.y)
  • 水平方向对齐中心(X、Z 坐标相同)
旁边(水平相邻)
  • 使用参考对象的侧面(worldMax.x 或 worldMin.x)
  • 加上目标对象的半宽(extents.x)
  • 垂直方向(Y 匹配)和深度方向(Z 匹配)对齐中心
前方 / 后方
  • 使用参考对象的前侧/后侧表面(worldMax.z 或 worldMin.z)
  • 加上目标对象的半深度(extents.z)
  • 对齐中心(X、Y 匹配)
位于特定偏移处
  • 从参考对象的中心开始
  • 加上自定义偏移
  • 仍需考虑目标对象的 extents,以确保正确落地

最佳实践

  1. 始终获取最新边界:不要缓存边界;放置前务必重新获取
  2. 考虑缩放:世界空间边界已包含对象缩放
  3. 考虑旋转:世界空间边界(Renderer.bounds)是轴对齐包围盒(AABB),会扩展以包住旋转后的网格。一个 1x0.1x1 的平面绕 Z 轴旋转 45 度后,其 AABB 会比平放时更高。这是正确行为——AABB 反映了对象实际占据的空间。放置时始终使用世界空间边界,以便自动处理旋转
  4. 处理缺少渲染器的情况:如果没有 Renderer,请检查是否有 Colliders;如果两者都没有,则向用户发出警告
  5. 说明计算过程:展示计算过程——告诉用户获取到的边界,以及位置的计算方式
  6. 局部位置与世界位置:set_transform 写入的是局部位置。对于未设置父对象的对象,局部位置等于世界位置;对于设置了父对象的对象则不相等,因此可以使用 parent.InverseTransformPoint(worldPos) 进行转换,或者通过 run_script 赋值 transform.position,并向用户报告这两个值
  7. 处理预制件:实例化预制件时,在实例化后获取其边界

处理边缘情况

对象没有 Renderer 或 Collider
  • 警告用户无法确定边界
  • 建议添加 Collider,或询问手动尺寸
  • 将对象枢轴点处的尺寸视为零,作为回退方案
多个 Renderer(父对象带有子对象)
  • 如果父对象有 Renderer,则使用父对象的 Renderer
  • 如果父对象没有 Renderer,则计算子对象的合并边界
  • 向用户说明这一点
旋转对象
  • 世界空间边界(Renderer.bounds)是轴对齐包围盒(AABB),会扩展以包住旋转后的几何体
  • 旋转对象的 AABB 通常大于对象的视觉轮廓,将对象紧贴放置时可能会产生可见间隙
  • 这是预期行为——如果间隙看起来不理想,请提醒用户,并建议调整旋转或使用手动偏移
  • 如果用户需要紧贴旋转表面放置,请考虑使用对象的局部 forward/up/right 向量和 localBounds 计算表面点,而不是依赖 AABB
不规则形状
  • 边界是轴对齐的包围盒(AABB)——即与世界轴对齐、能够完整包住网格的最小盒体
  • 对于非盒状形状,它们可能明显大于可见网格(例如,斜置的梁,或具有空角的球体 AABB)
  • 放置出现间隙时,请向用户说明这一点
非常小或非常大的对象
  • 确认边界看起来合理(尺寸 > 0)
  • 如果 extents 极小(< 0.01)或极大(> 100),请发出警告

示例工作流

用户:“将 Cube 放在 Sphere 顶部”

  1. 获取 Sphere 的组件并提取边界:

    • 尺寸:[2, 2, 2],位置:[0, 1, 0]
    • 世界中心:[0, 1, 0],Extents:[1, 1, 1]
    • 世界最大 Y:2
  2. 获取 Cube 的组件并提取边界:

    • 尺寸:[1, 1, 1],当前位置:[5, 0, 0]
    • Extents:[0.5, 0.5, 0.5]
  3. 计算新位置:

    • 目标 Y:2(Sphere 顶部)+ 0.5(Cube 半高)= 2.5
    • 目标 X:0(Sphere 中心 X)
    • 目标 Z:0(Sphere 中心 Z)
    • 新位置:[0, 2.5, 0]
  4. 将位置应用到 Cube

  5. 报告:“已将 Cube 放置在 Sphere 顶部,位置为 [0, 2.5, 0]。Cube(尺寸 1x1x1)位于 Sphere 的顶面(Y=2)上。”

快速参考

Unity 中的坐标轴
  • X:右(+)/ 左(-)
  • Y:上(+)/ 下(-)
  • Z:前(+)/ 后(-)
常见边界属性

世界空间(Renderer.bounds、Collider.bounds)——用于放置:

  • bounds.center:AABB 的世界空间中心
  • bounds.size:AABB 的完整尺寸(考虑旋转和缩放)
  • bounds.extents:半尺寸(size / 2)
  • bounds.min:世界空间最小角点
  • bounds.max:世界空间最大角点

局部空间(Renderer.localBounds)——用于比较固有尺寸:

  • localBounds.center:相对于对象枢轴点的局部偏移
  • localBounds.size:固有尺寸(不考虑旋转)
  • localBounds.extents:半尺寸(size / 2)
放置公式
  • 上方:refMax.y + targetExtents.y
  • 下方:refMin.y - targetExtents.y
  • 右侧:refMax.x + targetExtents.x
  • 左侧:refMin.x - targetExtents.x
  • 前方:refMax.z + targetExtents.z
  • 后方:refMin.z - targetExtents.z
基于距离的放置公式
  • 右侧 X 个单位处:refMax.x + distance + targetExtents.x
  • 左侧 X 个单位处:refMin.x - distance - targetExtents.x
  • 前方 X 个单位处:refMax.z + distance + targetExtents.z
  • 后方 X 个单位处:refMin.z - distance - targetExtents.z
  • 上方 X 个单位处:refMax.y + distance + targetExtents.y
  • 下方 X 个单位处:refMin.y - distance - targetExtents.y
  • 相隔一定间隙放在旁边:refMax.x + gap + targetExtents.x(或左侧使用 refMin.x)

请记住

目标是直观、准确地放置对象。务必:

  1. 使用 run_script 和边界读取器,从运行中的 Editor 中获取组件的实际边界——get_component_properties 不会公开 bounds
  2. 计算世界空间位置
  3. 考虑对象的范围(半尺寸)
  4. 应用位置时留意局部空间与世界空间:set_transform 使用局部空间
  5. 向用户说明计算过程
  6. 切勿猜测对象尺寸
  7. 切勿不依据边界就将对象放置在任意位置

评论

支持 Markdown