关于 hz-unity-placement
hz-unity-placement 是指导 Unity 场景对象相对摆放的技能,面向操作 GameObject 的开发者。它要求基于实时世界空间边界框计算位置,以减少重叠并处理旋转、缩放和父子层级差异。
主要功能
- 01读取 Renderer 或 Collider 边界
- 02使用世界空间边界计算位置
- 03按相对方向与间距定位
- 04处理父对象与本地坐标差异
- 05验证并说明摆放结果
适用场景
- 01将物体放在桌面或其他物体上
- 02按指定距离在物体旁摆放对象
- 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:确定涉及的对象
用户请求放置对象时:
- 确定目标对象(要放置/移动的对象)
- 确定参照对象(放置目标对象时所依据的对象)
- 记录所需的空间关系(上方、旁边等)
步骤 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,以确保正确落地
最佳实践
- 始终获取最新边界:不要缓存边界;放置前务必重新获取
- 考虑缩放:世界空间边界已包含对象缩放
- 考虑旋转:世界空间边界(
Renderer.bounds)是轴对齐包围盒(AABB),会扩展以包住旋转后的网格。一个 1x0.1x1 的平面绕 Z 轴旋转 45 度后,其 AABB 会比平放时更高。这是正确行为——AABB 反映了对象实际占据的空间。放置时始终使用世界空间边界,以便自动处理旋转 - 处理缺少渲染器的情况:如果没有 Renderer,请检查是否有 Colliders;如果两者都没有,则向用户发出警告
- 说明计算过程:展示计算过程——告诉用户获取到的边界,以及位置的计算方式
- 局部位置与世界位置:
set_transform写入的是局部位置。对于未设置父对象的对象,局部位置等于世界位置;对于设置了父对象的对象则不相等,因此可以使用parent.InverseTransformPoint(worldPos)进行转换,或者通过run_script赋值transform.position,并向用户报告这两个值 - 处理预制件:实例化预制件时,在实例化后获取其边界
处理边缘情况
对象没有 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 顶部”
-
获取 Sphere 的组件并提取边界:
- 尺寸:[2, 2, 2],位置:[0, 1, 0]
- 世界中心:[0, 1, 0],Extents:[1, 1, 1]
- 世界最大 Y:2
-
获取 Cube 的组件并提取边界:
- 尺寸:[1, 1, 1],当前位置:[5, 0, 0]
- Extents:[0.5, 0.5, 0.5]
-
计算新位置:
- 目标 Y:2(Sphere 顶部)+ 0.5(Cube 半高)= 2.5
- 目标 X:0(Sphere 中心 X)
- 目标 Z:0(Sphere 中心 Z)
- 新位置:[0, 2.5, 0]
-
将位置应用到 Cube
-
报告:“已将 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)
请记住
目标是直观、准确地放置对象。务必:
- 使用
run_script和边界读取器,从运行中的 Editor 中获取组件的实际边界——get_component_properties不会公开bounds - 计算世界空间位置
- 考虑对象的范围(半尺寸)
- 应用位置时留意局部空间与世界空间:
set_transform使用局部空间 - 向用户说明计算过程
- 切勿猜测对象尺寸
- 切勿不依据边界就将对象放置在任意位置