1. 项目概述:为什么我们需要一个运行时编辑器?
在Unity开发中,尤其是涉及到工具链、关卡编辑器、数据配置工具或者需要给非技术策划、美术人员提供调参能力的项目里,一个能在游戏运行状态下直接操作场景对象、编辑预制体的工具,其价值不言而喻。想象一下,你的策划同事想微调一下场景中某个NPC的巡逻路径点,或者美术想实时调整一下某个特效粒子的发射参数,难道每次都要你——程序员——暂停游戏、退出运行模式、在编辑器里修改、再重新运行吗?这效率太低了。
这就是“Runtime Editor”(运行时编辑器)要解决的问题。它本质上是在游戏运行时,复现了Unity编辑器的一部分核心交互功能,特别是句柄操作和预制体动态编辑。句柄操作,就是我们在编辑器中看到的那个红绿蓝三色箭头(移动)、彩色圆圈(旋转)和彩色方块(缩放)控件,允许我们通过鼠标拖拽直观地改变物体的Transform属性。而预制体动态编辑,则更进一步,允许在运行时实例化、修改甚至保存预制体资产的状态。
这个项目标题“【Unity】Runtime Editor实战:句柄操作与预制体动态编辑”直指核心:我们要动手实现一个能在游戏运行中使用的、具备可视化交互能力的编辑工具。这不仅仅是调用几个API那么简单,它涉及到输入事件的重定向、3D空间中的射线拾取与坐标转换、UI与3D场景的交互、以及Unity序列化系统的深入理解。接下来,我将以一个实际构建者的角度,带你拆解其中的每一个技术环节,分享我趟过的坑和总结出的最佳实践。
2. 核心思路与架构设计
2.1 需求拆解与方案选型
首先,我们得明确这个运行时编辑器具体要做什么。从标题和常见需求来看,核心功能点可以拆解为:
- 对象选择:在运行时场景中,用鼠标点击选中一个GameObject。
- 句柄渲染与交互:为被选中的对象绘制可交互的移动、旋转、缩放Gizmo(句柄)。
- 句柄操作:通过鼠标拖拽句柄,实时改变选中对象的Transform(位置、旋转、缩放)。
- 预制体支持:能够从资源列表中拖出或创建预制体实例到场景中。
- 动态编辑:对场景中的预制体实例进行修改(如调整组件参数),并考虑是否支持将修改保存回原预制体资产或生成新的运行时数据。
市面上有现成的Asset Store资源,比如提到的“Runtime Transform Gizmo”。在项目初期或原型阶段,直接使用这些资源可以快速验证想法,非常划算。但如果你需要深度定制、与自身项目框架高度集成、或者想彻底掌握其原理,自己动手实现是更好的选择。我的建议是:先研究成熟资产的设计,再着手自研。这能帮你避开很多基础设计陷阱。
自研方案的核心架构通常分为三层:
- 交互层:处理鼠标/触摸输入,将其转换为场景空间中的操作意图(如“点击了哪个物体”、“开始拖拽移动句柄的X轴”)。
- 可视化层:负责在屏幕上绘制Gizmo句柄。这可以用GL库(
GL.Begin,GL.Vertex)绘制纯色几何体,也可以用Mesh生成更复杂的带光照的3D模型。GL绘制简单高效,适合基础句柄;Mesh方式更美观,可定制性强。 - 数据层:连接被操作的GameObject的Transform组件,将交互层的操作意图转化为实际的坐标、欧拉角或缩放值的变化,并应用上去。对于预制体编辑,还需要处理与
PrefabUtility(编辑器API)或自定义序列化系统的对接。
注意:
PrefabUtility是UnityEditor命名空间下的API,在真机运行时是不可用的。这意味着“保存回原预制体资产”这个功能在移动端、WebGL等平台是无法直接实现的。运行时编辑通常指的是修改当前游戏实例中的数据,这些修改可以是临时的,也可以通过自定义的配置文件系统保存为游戏存档或场景数据的一部分。这是设计初期就必须明确的关键约束。
2.2 关键技术点:输入、射线与坐标转换
整个系统的基石是正确地将2D屏幕输入映射到3D场景空间。这个过程几乎发生在每一帧,其准确性直接决定了操作手感。
核心流程如下:
- 获取输入:在
Update()中监听Input.GetMouseButtonDown(0)等事件。 - 屏幕射线:使用
Camera.ScreenPointToRay(Input.mousePosition),从摄像机通过鼠标点击的屏幕点,向场景发射一条无限远的射线。 - 物理检测:使用
Physics.Raycast或Physics.RaycastAll(如果需要处理重叠对象)来检测射线击中了哪些带有Collider的物体。这里就是对象选择功能的实现。 - 句柄拾取:这是难点。句柄本身可能没有Collider,或者为了精确拾取,我们需要进行更复杂的数学计算。常见做法是,为每个句柄(如X轴箭头)定义一个“有效拾取区域”。当鼠标点击时,不仅检测物体,还要计算鼠标位置与每个句柄在屏幕空间投影的距离或进行射线与自定义几何体的相交测试。
// 伪代码示例:简单的对象选择 void Update() { if (Input.GetMouseButtonDown(0)) { Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit)) { SelectObject(hit.collider.gameObject); } } }坐标转换的坑:操作句柄时,比如拖拽移动句柄,我们需要将鼠标在屏幕上的2D位移,转换为物体在世界空间或本地空间下的3D位移。这里涉及到:
- 操作平面:移动操作通常将位移投影到一个平面上。例如,移动X轴句柄,实质是在一个垂直于摄像机视角且包含物体X轴方向的平面上进行拖拽。计算这个平面与鼠标射线的新交点,与上一帧的交点做差,就得到了位移向量。
- 坐标系:要明确是在世界坐标系(World Space)还是父物体坐标系(Local Space)下操作。通常移动/缩放使用世界坐标系更直观,旋转则可能使用本地坐标系。这需要在绘制句柄和计算位移时保持一致。
3. 句柄操作的实现细节
3.1 移动句柄的实现
移动句柄通常由三个相互垂直的箭头(代表X, Y, Z轴)和一个中心方块(代表在摄像机视角平面上自由移动)组成。
绘制:可以使用GL.Begin(GL.LINES)和GL.Vertex来绘制箭头线段。计算箭头的起点(物体位置)、终点(物体位置 + 轴方向 * 句柄长度),并将这些世界坐标通过Camera.WorldToScreenPoint转换后传递给GL。GL绘制需要在OnPostRender回调或特定摄像机的OnRenderObject中进行。
交互:
- 当鼠标按下,判断拾取的是哪个轴(或中心方块)。这可以通过计算鼠标位置与每个轴在屏幕空间投影线段的距离来实现。
- 如果拾取到某个轴(如X轴),则计算一个操作平面。这个平面通常由摄像机的右向量(或上向量)与当前轴方向叉乘得到法线,从而确定一个平面。将当前帧和上一帧的鼠标射线与该平面求交,得到两个世界空间点,其差值就是物体应移动的沿该轴方向的位移。注意,这里要使用
Vector3.Project将位移向量投影到轴方向上,防止因为摄像机角度产生偏移。 - 如果拾取的是中心方块,则操作平面通常是垂直于摄像机视线的平面(即摄像机的前向向量的法平面)。将鼠标位移投影到这个平面上,实现物体在屏幕平面内的自由拖动。
// 伪代码:处理X轴移动拖拽 if (currentGizmoType == GizmoType.MoveX) { // 假设 plane 是之前计算好的X轴操作平面 float enter; if (plane.Raycast(currentMouseRay, out enter)) { Vector3 hitPoint = currentMouseRay.GetPoint(enter); if (isDragging) { Vector3 delta = hitPoint - previousHitPoint; delta = Vector3.Project(delta, axisX); // 将位移严格限制在X轴方向 selectedObject.transform.position += delta; } previousHitPoint = hitPoint; } }3.2 旋转与缩放句柄
旋转句柄:通常绘制为围绕物体的三个彩色圆环。拾取判断是计算鼠标位置到每个圆环在屏幕空间投影(近似为一个圆)的距离。拖拽旋转时,需要计算鼠标围绕物体中心点的角度变化。一种通用方法是:获取鼠标从按下点到当前点的向量(在屏幕空间),以及物体中心点在屏幕空间的投影点。计算这两个向量之间的角度差,并将其转换为绕特定轴(如Y轴)的旋转角度。这里要处理好旋转的正方向(顺/逆时针)和灵敏度。
缩放句柄:可以是每个轴末端的方块,或者一个统一的立方体。实现原理与移动类似,但位移变化应用给物体的localScale。对于非均匀缩放,拖拽某个轴的句柄就只改变该轴的比例。对于中心句柄,则可以等比例缩放所有轴。需要注意的是,直接修改localScale可能会影响子物体,需要根据需求决定是否希望子物体也随之缩放。
3.3 操作手感优化与常见问题
- 句柄大小随距离变化:句柄在屏幕上应保持大致相同的视觉大小,否则物体远离摄像机时会小到无法点击。可以在绘制或拾取判断时,根据物体到摄像机的距离动态调整句柄的“有效拾取半径”或绘制尺寸。
- 深度测试与遮挡:句柄不应该被场景中的其他物体遮挡。在绘制GL时,可以设置
GL.LoadPixelMatrix();并禁用深度测试(GL.DepthTest(false)),让句柄始终绘制在最上层。但拾取时,如果句柄没有Collider,则不存在遮挡问题;如果有,可能需要设置特定的Layer并让射线忽略其他层。 - 操作粘滞与抖动:在计算位移或旋转时,如果直接使用每帧的鼠标差值,可能会因为帧率波动或鼠标微动导致操作不跟手或抖动。可以考虑使用平滑插值(如
Vector3.Lerp)或积累一个微小的死区阈值。 - 多坐标系切换:提供世界坐标系和本地坐标系的切换按钮,并相应地重新计算句柄的绘制方向(
transform.right,transform.up,transform.forwardvsVector3.right,Vector3.up,Vector3.forward)。
4. 预制体的动态编辑实现
4.1 运行时实例化与管理
预制体的动态编辑第一步是能把它们“放”到场景里。在运行时,我们使用GameObject.Instantiate(prefab)来实例化预制体。这里的prefab是一个GameObject类型的引用,通常需要通过Resources加载或Addressables/AssetBundle系统获取。
我们需要在运行时编辑器的UI部分创建一个资源面板,列出可用的预制体。当用户从UI中拖拽一个预制体项到场景视图,流程如下:
- 监听UI拖拽事件开始,记录被拖拽的预制体ID或引用。
- 在场景视图的
Update中,如果处于拖拽状态,则根据当前鼠标位置发射射线,检测与场景中某个平面(如地面)的碰撞点。 - 实时更新一个“预览幽灵”物体的位置,这个幽灵物体可以是预制体的一个半透明拷贝。
- 当鼠标释放时,在碰撞点正式实例化该预制体,并自动将其设置为当前选中对象,激活句柄。
管理实例化对象:最好维护一个列表,记录所有通过运行时编辑器创建的对象,以便进行批量操作(如全选、删除、保存状态)。
4.2 组件参数的动态修改
选中一个对象后,除了Transform,我们通常还希望修改其身上其他组件的属性,比如Light的强度、Renderer的材质、脚本的公开变量等。这就需要动态生成一个属性编辑器UI。
实现方式:
- 反射:使用C#的反射(Reflection)API,遍历选中对象所有组件,再遍历每个组件的公共字段(Field)和属性(Property)。利用
Type.GetFields()和Type.GetProperties()获取信息,然后根据类型(int, float, string, bool, Enum, Color, Vector3等)生成对应的UI控件(InputField, Slider, Toggle, Dropdown, ColorPicker等)。这是最通用但性能开销相对较大的方法,适合原型或工具开发。 - 预定义编辑器:针对项目常用的组件类型(如自定义的
MyUnitScript),预先编写好对应的UI绘制代码。这种方式性能好,与项目结合紧密,但扩展性差,每增加一种新组件就需要写新的UI代码。 - 混合模式:对核心组件使用预定义编辑器保证体验和性能,对其他组件回退到反射模式。
以反射生成一个float字段的Slider为例:
// 伪代码,实际需考虑UI布局和事件绑定 FieldInfo field = component.GetType().GetField("health"); if (field != null && field.FieldType == typeof(float)) { float currentValue = (float)field.GetValue(component); // 在UI上创建一个Slider,其value设为currentValue // 监听Slider的onValueChanged事件 slider.onValueChanged.AddListener((newValue) => { field.SetValue(component, newValue); // 可能需要标记对象为“已修改” }); }4.3 状态保存与序列化
这是最具挑战性的一环。在编辑器中,我们可以用PrefabUtility.ApplyPrefabInstance将实例的修改应用回预制体。但在运行时,此路不通。
常见的运行时保存方案:
- 场景数据资产:定义一个
ScriptableObject,比如RuntimeSceneData,里面包含一个序列化列表,记录每个动态创建或修改的对象的标识符(如GUID或唯一名称)、其预制体引用、以及其所有被修改的组件和属性值。游戏启动时加载这个ScriptableObject,按照数据重新实例化和配置场景。修改后,将数据写回这个ScriptableObject(在支持文件读写的平台,如PC、主机)。 - JSON/二进制序列化:将上述场景数据序列化为JSON或二进制文件。Unity的
JsonUtility可以序列化大部分基础类型和可序列化类。对于复杂对象(如对Material、Texture的引用),需要建立资源索引系统(如使用资源路径或Addressables的Key)。 - 差分存储:不存储整个场景,只存储相对于原始预制体的“修改集”。加载时,先实例化原始预制体,再应用修改集。这更节省存储空间。
一个简单的可序列化修改记录结构可能如下:
[System.Serializable] public class ObjectModification { public string prefabId; // 预制体标识 public string instanceId; // 实例唯一ID(可用于运行时查找) public Vector3 position; public Quaternion rotation; public Vector3 scale; public List<ComponentModification> componentMods; // 其他组件修改列表 } [System.Serializable] public class ComponentModification { public string componentType; // 组件类型全名 public List<PropertyModification> propertyMods; } [System.Serializable] public class PropertyModification { public string propertyName; public string valueString; // 将值转换为字符串存储,根据类型解析 public string valueType; }实操心得:运行时序列化的黄金法则是“只序列化你需要的数据”。不要试图序列化整个
GameObject或Component。明确哪些属性是可编辑的、需要保存的,为它们设计轻量级的、只包含基础数据类型的数据结构。对于资源引用,序列化其资源路径或在一个资源清单中的索引ID。
5. 性能优化与工程化实践
5.1 渲染与交互性能
- Gizmo绘制:GL绘制虽然直接,但每帧绘制大量线段对CPU有一定压力。如果场景中可编辑对象很多,且都需要显示句柄,可以考虑改用Command Buffer或在GPU上绘制的方式。更常见的优化是:只绘制当前选中对象的句柄。
- 射线检测:为可交互的物体和句柄分配特定的Layer。在进行拾取射线检测时,使用
LayerMask参数只检测这些层,避免对场景中所有Collider进行不必要的检测,可以大幅提升性能。 - 属性编辑器UI:使用反射动态生成UI在对象选中时发生一次即可,不要每帧都生成。使用对象池管理生成的UI控件,当选中新对象时复用旧的控件,只更新其绑定的数据和事件监听器。
5.2 代码结构与可扩展性
一个好的运行时编辑器应该易于扩展新的句柄类型或新的组件编辑器。
- 策略模式:将移动、旋转、缩放等不同操作模式抽象为独立的类(如
IManipulationStrategy),每个类负责自己的绘制、拾取和更新逻辑。通过一个上下文类来管理当前激活的策略。 - 命令模式:所有通过编辑器对场景对象做出的修改,都应该封装成一个“命令”(Command)对象(如
MoveCommand,ChangePropertyCommand)。这带来了两个巨大好处:撤销/重做功能的实现变得非常简单(维护一个命令栈即可);以及网络同步的潜力(命令可以被序列化并发送到服务器或其他客户端)。 - 事件系统:使用C#事件或UnityEvent来广播编辑器的状态变化,如
OnSelectionChanged,OnObjectTransformed,OnPrefabInstantiated。这样,游戏的其他系统(如音效、存档自动提示、网络模块)可以监听这些事件并做出反应,而不需要与编辑器代码紧密耦合。
5.3 常见问题排查实录
句柄拾取不准,尤其是旋转时:
- 原因:屏幕空间到3D圆环的拾取计算存在误差,或者未考虑透视投影导致的形状畸变。
- 解决:将3D圆环离散成一系列线段,计算鼠标点到每条线段投影的距离,取最小值。或者使用一个更简单的近似:将3D圆环投影到屏幕空间后,将其视为一个2D圆(圆心为物体中心投影点,半径根据距离计算),直接计算鼠标点到该2D圆圆心的距离。
在UI上操作时,误触发了场景中的句柄:
- 原因:输入事件没有正确处理UI遮挡。Unity的EventSystem会处理UI点击,如果点击在UI上,通常不应该再触发场景中的射线检测。
- 解决:在检测鼠标点击时,先使用
EventSystem.current.IsPointerOverGameObject()判断鼠标是否在UI上。如果是,则跳过场景拾取逻辑。
修改的属性在保存后重新加载时无效:
- 原因:序列化时丢失了类型信息或引用。例如,你修改了一个
public Material myMat;,保存时只存了myMat.name,加载时通过名称找不到对应的Material资源。 - 解决:建立稳定的资源引用系统。对于Unity内置资源(Material, Prefab),序列化其资源在Resources文件夹下的路径,或其在AssetDatabase中的GUID(仅限编辑器环境下)。对于Addressables,序列化其Addressable Key。确保加载逻辑能通过这些标识符准确找到资源。
- 原因:序列化时丢失了类型信息或引用。例如,你修改了一个
移动物体时,句柄位置没有实时更新:
- 原因:句柄的绘制位置是在
LateUpdate或OnPostRender中根据物体当前transform.position计算的。如果你的移动操作逻辑也在Update中,且执行顺序在绘制之前,那么同一帧内绘制使用的就是更新后的位置,看起来是实时的。但如果顺序反了,就会出现一帧的延迟。 - 解决:确保句柄位置的计算和绘制发生在所有物体位置更新之后。可以将绘制逻辑放在
LateUpdate中,或者使用一个在所有Update之后执行的脚本执行顺序。
- 原因:句柄的绘制位置是在
实现一个功能完备、体验流畅的Runtime Editor是一个系统工程,它考验着你对Unity引擎底层交互、3D数学、UI系统和软件架构的理解。从最简单的对象选择和移动开始,逐步叠加旋转、缩放、属性编辑、保存加载等功能,并持续进行优化和重构,最终你将得到一个强大且完全贴合自己项目需求的内部开发工具。这个过程本身,就是对Unity开发能力的一次极佳锤炼。