
1. 项目概述与核心价值在Unity3D游戏开发或者交互式应用开发中我们经常会遇到一个看似简单但实现起来颇为棘手的需求在游戏运行时Runtime允许用户像在编辑器Editor里一样自由地移动、旋转、缩放场景中的模型对象并且能将修改后的状态持久化保存下来以便下次加载时恢复。这个功能听起来像是编辑器功能的延伸但它的应用场景远比想象中广泛。无论是用于制作关卡编辑器、构建玩家自定义的家园系统、开发AR/VR场景布置工具还是实现一个简易的3D建模预览与调整工具这个功能都是核心交互的基石。很多开发者尤其是刚接触Unity不久的朋友可能会觉得这个功能实现起来很简单——不就是监听输入然后修改Transform组件最后把数据存下来吗但实际动手后就会发现坑点不少如何实现平滑、直观的拖拽操作如何将世界坐标、局部坐标的转换处理得明明白白如何设计一个既高效又易扩展的数据保存结构更不用说还要考虑撤销重做、多选操作、不同坐标系切换等进阶需求了。网上的代码片段往往只解决了其中一环缺乏一个完整、健壮、可复用的解决方案。今天我就结合自己多年的项目经验从零开始手把手带你实现一个完整的运行时物体编辑与保存系统。我会不仅告诉你代码怎么写更会深入解释每一个设计决策背后的“为什么”并分享那些在官方文档里找不到的“踩坑”心得和性能优化技巧。文章末尾会提供完整的、可直接集成到你项目中的C#源码。2. 核心设计思路与架构拆解在动手写代码之前我们先花点时间把整个系统的设计思路理清楚。一个好的架构能让我们后续的开发事半功倍也更容易应对需求的变化。2.1 功能模块分解整个系统可以清晰地划分为三个核心层交互控制层负责处理用户的输入鼠标、触摸将抽象的点击、拖拽事件转化为对场景中特定物体进行移动、旋转、缩放的具体操作意图。这是用户体验最直接的一层。视觉反馈层当用户选中一个物体或进行操作时需要提供清晰的视觉反馈。最经典的就是显示一个三轴XYZ的操控手柄Gizmo比如Unity编辑器自用的那种。这一层让操作从“盲操”变得“可见”。数据持久层当编辑操作完成后需要将场景中所有可编辑物体的最终状态位置、旋转、缩放序列化为一种格式如JSON、二进制并保存到磁盘或发送到服务器。在需要时又能从存储介质中读取数据并准确地还原场景。2.2 关键技术选型与考量2.2.1 交互检测射线投射Raycasting vs. 碰撞体Collider这是实现选中功能的基石。两种主流方案方案A为每个可编辑物体添加碰撞体Collider。通过Physics.Raycast进行检测。这是最标准、性能较好的方式尤其适合复杂网格模型。但需要注意如果你的模型原本没有碰撞体动态添加可能会影响物理模拟对于大量物体需要管理碰撞体的启用/禁用以优化性能。方案B使用Bounds或网格顶点进行数学相交检测。这种方式更轻量不依赖物理系统但实现复杂度高对于非凸面体检测可能不准确。实操心得对于绝大多数情况方案A是首选。我推荐使用MeshCollider并勾选Convex凸面选项或者使用简化的BoxCollider/CapsuleCollider来近似复杂形状这能在精度和性能间取得很好的平衡。务必确保物体的Layer被正确设置并在射线投射时指定对应的LayerMask以避免选中不需要的物体如UI、地面。2.2.2 操控手柄Gizmo的实现这是视觉反馈层的核心。有三种实现思路使用Unity的Handles类这是Editor命名空间下的类只能在编辑器模式下运行。所以运行时方案首先排除它。纯Shader绘制使用GL库或编写自定义Shader来绘制线条和箭头。这种方式最灵活、性能高但实现难度最大需要较强的图形学知识。使用3D模型预制件预先制作好表示X、Y、Z轴的箭头、圆环等模型作为预制件Prefab动态生成。这是最推荐、最易上手的方式。我们可以为每个轴创建一个子物体并附上单独的碰撞体这样不仅能显示还能通过射线检测来知道用户具体想操作哪个轴。2.2.3 数据序列化方案保存Transform数据看似简单但细节决定成败。序列化库强烈推荐使用Newtonsoft.Json即Json.NET因为它功能强大、社区支持好能轻松处理循环引用、复杂类型等。Unity 2020后内置的JsonUtility虽然轻量但对数据结构限制较多如不支持字典、多态。保存什么数据绝对保存物体的唯一标识如name、instanceID或自定义GUID、position(Vector3)、rotation(Quaternion)、scale(Vector3)。关键考量rotation应保存为四元数Quaternion而非欧拉角Euler Angles因为四元数能避免万向节死锁且插值更平滑。保存时要明确是用本地坐标Local还是世界坐标World。这取决于你的场景结构。如果物体都在同一个根节点下保存本地坐标更利于整体移动如果是散落在场景各处的独立物体保存世界坐标更直观。数据结构设计定义一个[System.Serializable]的类比如SceneObjectData包含上述字段。然后用一个ListSceneObjectData来保存整个场景的状态。这个列表本身也可以被序列化。3. 分步实现从零搭建编辑系统理论说得差不多了现在我们开始动手编码。我会按照模块逐个击破。3.1 第一步创建可编辑物体管理器与数据模型首先我们创建一个单例管理器它将是整个系统的中枢。using System.Collections.Generic; using UnityEngine; public class RuntimeEditorManager : MonoBehaviour { public static RuntimeEditorManager Instance { get; private set; } // 当前选中的物体 public GameObject SelectedObject { get; private set; } // 所有可编辑物体的列表可通过代码注册或扫描标签添加 public ListGameObject EditableObjects new ListGameObject(); // 操控手柄的预制件 public GameObject GizmoPrefab; // 当前激活的操控手柄实例 private GameObject _currentGizmo; // 操作模式移动、旋转、缩放 public enum EditMode { None, Translate, Rotate, Scale } private EditMode _currentMode EditMode.Translate; private void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; } } // 外部调用设置选中的物体 public void SelectObject(GameObject obj) { if (SelectedObject obj) return; DeselectCurrentObject(); SelectedObject obj; if (obj ! null GizmoPrefab ! null) { // 在选中物体位置生成操控手柄 _currentGizmo Instantiate(GizmoPrefab, obj.transform.position, Quaternion.identity); // 可以将Gizmo设为选中物体的子物体使其跟随移动但操作时需注意坐标系 // _currentGizmo.transform.SetParent(obj.transform); _currentGizmo.transform.SetParent(null); // 独立存在更易管理 _currentGizmo.GetComponentGizmoController().SetTarget(obj); } } private void DeselectCurrentObject() { SelectedObject null; if (_currentGizmo ! null) { Destroy(_currentGizmo); _currentGizmo null; } } // 切换操作模式 public void SetEditMode(EditMode mode) { _currentMode mode; if (_currentGizmo ! null) { _currentGizmo.GetComponentGizmoController().SwitchMode(mode); } } }接着定义我们的数据模型类。这个类需要可序列化以便保存为JSON。using System; using UnityEngine; [Serializable] public class SceneObjectData { public string ObjectId; // 唯一标识建议使用GUID这里用名字简单示例 public Vector3 Position; public Quaternion Rotation; public Vector3 Scale; // 辅助构造函数 public SceneObjectData(GameObject go) { ObjectId go.name; // 生产环境请使用更可靠的ID Position go.transform.position; Rotation go.transform.rotation; Scale go.transform.localScale; } // 将数据应用到物体上 public void ApplyTo(GameObject go) { if (go.name ! ObjectId) { Debug.LogWarning($ID不匹配数据ID:{ObjectId}, 物体名:{go.name}); return; } go.transform.position Position; go.transform.rotation Rotation; go.transform.localScale Scale; } } [Serializable] public class SceneData { public ListSceneObjectData Objects new ListSceneObjectData(); }3.2 第二步实现物体选中与基础交互我们需要一个挂在每个可编辑物体上的脚本用于处理被选中的逻辑。using UnityEngine; public class EditableObject : MonoBehaviour { void OnMouseDown() // 简单示例实际可能需要更复杂的输入管理 { if (RuntimeEditorManager.Instance ! null) { RuntimeEditorManager.Instance.SelectObject(this.gameObject); } } // 也可以由管理器统一通过射线检测来调用此方法 public void OnSelected() { // 这里可以添加被选中时的视觉效果如高亮轮廓 Debug.Log($物体 {name} 被选中。); } public void OnDeselected() { // 取消高亮等效果 } }然后创建一个输入控制器它独立于具体物体负责监听全局的点击事件并进行射线检测。using UnityEngine; public class InputController : MonoBehaviour { public LayerMask EditableLayer; // 在Inspector中指定可编辑物体所在的层 void Update() { if (Input.GetMouseButtonDown(0)) // 左键点击 { HandleSelection(); } } void HandleSelection() { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; // 进行射线检测只检测指定层 if (Physics.Raycast(ray, out hit, Mathf.Infinity, EditableLayer)) { EditableObject editable hit.collider.GetComponentEditableObject(); if (editable ! null) { RuntimeEditorManager.Instance?.SelectObject(hit.collider.gameObject); } } else { // 点击空白处取消选中 RuntimeEditorManager.Instance?.SelectObject(null); } } }注意事项OnMouseDown方法要求物体必须有Collider且摄像机上需要有PhysicsRaycaster组件如果是UI则需要GraphicRaycaster。使用独立的InputController进行射线检测是更推荐的做法因为它将输入逻辑集中管理更灵活也更容易扩展到触摸、VR控制器等输入设备。3.3 第三步构建运行时操控手柄Gizmo这是最具挑战也最有成就感的部分。我们需要创建一个GizmoController脚本和一个对应的手柄预制件。1. 制作预制件创建一个空物体命名为RuntimeGizmo。为其创建三个子物体分别代表X红色、Y绿色、Z蓝色轴。每个子物体可以是一个细长的Cube箭头身体加一个稍大的Cube箭头头部的组合。为每个轴的子物体添加Collider如BoxCollider和单独的脚本组件GizmoAxis并设置一个Axis枚举X, Y, Z来标识自己。在RuntimeGizmo上添加GizmoController脚本。2. 编写GizmoAxis脚本using UnityEngine; public class GizmoAxis : MonoBehaviour { public enum AxisType { X, Y, Z } public AxisType Axis; public RuntimeGizmoController.GizmoMode Mode; // 属于移动、旋转还是缩放手柄的一部分 private void OnMouseDown() { if (GizmoController.Instance ! null) { GizmoController.Instance.OnAxisSelected(this); } } }3. 编写核心的GizmoController脚本这个脚本负责处理手柄的显示、模式切换以及最核心的拖拽计算。using UnityEngine; public class GizmoController : MonoBehaviour { public enum GizmoMode { Translate, Rotate, Scale } private GizmoMode _currentMode GizmoMode.Translate; private GameObject _targetObject; // 正在被操控的物体 private GizmoAxis _selectedAxis; // 当前选中的轴 private Vector3 _initialOffset; private Plane _dragPlane; private Vector3 _initialTargetPosition; private Quaternion _initialTargetRotation; private Vector3 _initialTargetScale; private Vector3 _initialMouseWorldPos; public void SetTarget(GameObject target) { _targetObject target; UpdateGizmoPosition(); SwitchMode(_currentMode); // 根据当前模式更新显示 } public void SwitchMode(GizmoMode mode) { _currentMode mode; // 这里可以控制不同模式的子手柄箭头、圆环、方块的显示/隐藏 foreach (Transform child in transform) { child.gameObject.SetActive(child.name.Contains(mode.ToString())); } // 更精细的控制需要为每个手柄部件打上Tag或设置自定义属性 } public void OnAxisSelected(GizmoAxis axis) { _selectedAxis axis; _initialTargetPosition _targetObject.transform.position; _initialTargetRotation _targetObject.transform.rotation; _initialTargetScale _targetObject.transform.localScale; // 计算拖拽平面。对于移动平面垂直于摄像机看向轴的方向。 Vector3 cameraForward Camera.main.transform.forward; Vector3 axisDirection GetAxisDirection(axis.Axis); _dragPlane new Plane(Vector3.Cross(axisDirection, cameraForward).normalized, _initialTargetPosition); // 将初始鼠标位置投影到拖拽平面上 Ray initialRay Camera.main.ScreenPointToRay(Input.mousePosition); float enter; if (_dragPlane.Raycast(initialRay, out enter)) { _initialMouseWorldPos initialRay.GetPoint(enter); _initialOffset _initialMouseWorldPos - _initialTargetPosition; } } void Update() { if (_selectedAxis ! null Input.GetMouseButton(0)) { HandleDrag(); } else if (Input.GetMouseButtonUp(0)) { _selectedAxis null; } // 让Gizmo始终面向摄像机Billboard效果但保持各轴方向的世界方向不变。 // 更佳做法是让Gizmo作为_targetObject的子物体但旋转独立。 if (_targetObject ! null) { transform.position _targetObject.transform.position; // 对于移动手柄可以让手柄整体朝向摄像机但轴的方向保持世界坐标。 // transform.rotation Quaternion.LookRotation(transform.position - Camera.main.transform.position); } } void HandleDrag() { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); float enter; if (!_dragPlane.Raycast(ray, out enter)) return; Vector3 currentMouseWorldPos ray.GetPoint(enter); Vector3 delta currentMouseWorldPos - _initialMouseWorldPos; switch (_currentMode) { case GizmoMode.Translate: HandleTranslation(delta); break; case GizmoMode.Rotate: HandleRotation(delta); break; case GizmoMode.Scale: HandleScaling(delta); break; } } void HandleTranslation(Vector3 delta) { Vector3 axisDir GetAxisDirection(_selectedAxis.Axis); // 计算在选中轴方向上的位移分量 float movementOnAxis Vector3.Dot(delta, axisDir); Vector3 newPosition _initialTargetPosition axisDir * movementOnAxis; _targetObject.transform.position newPosition; UpdateGizmoPosition(); } void HandleRotation(Vector3 delta) { // 旋转计算相对复杂一种常见方法是根据鼠标移动的屏幕距离来计算旋转角度 // 这里简化处理绕选中轴旋转角度与鼠标在屏幕上的移动距离成正比 float rotationSpeed 0.5f; float angle delta.magnitude * rotationSpeed * Mathf.Rad2Deg; // 需要确定旋转方向这里可以根据鼠标移动与轴的关系计算叉积 Vector3 rotationAxis GetAxisDirection(_selectedAxis.Axis); _targetObject.transform.rotation Quaternion.AngleAxis(angle, rotationAxis) * _initialTargetRotation; } void HandleScaling(Vector3 delta) { Vector3 axisDir GetAxisDirection(_selectedAxis.Axis); float scaleFactor 1.0f Vector3.Dot(delta, axisDir) * 0.1f; // 0.1是缩放灵敏度 Vector3 newScale _initialTargetScale; switch (_selectedAxis.Axis) { case GizmoAxis.AxisType.X: newScale.x * scaleFactor; break; case GizmoAxis.AxisType.Y: newScale.y * scaleFactor; break; case GizmoAxis.AxisType.Z: newScale.z * scaleFactor; break; } _targetObject.transform.localScale newScale; } Vector3 GetAxisDirection(GizmoAxis.AxisType axis) { switch (axis) { case GizmoAxis.AxisType.X: return Vector3.right; case GizmoAxis.AxisType.Y: return Vector3.up; case GizmoAxis.AxisType.Z: return Vector3.forward; default: return Vector3.zero; } } void UpdateGizmoPosition() { if (_targetObject ! null) { transform.position _targetObject.transform.position; } } }核心难点解析拖拽计算是手柄实现中最精妙的部分。关键在于_dragPlane的构建。我们不是让物体直接跟着鼠标在屏幕上的二维移动而是将鼠标射线与一个三维空间中的平面求交。这个平面通常垂直于摄像机视角与操作轴所构成的平面这样能保证拖拽操作是沿着我们期望的轴方向进行的感觉最自然。Vector3.Dot点乘运算用于计算鼠标位移在操作轴方向上的投影长度这正是我们需要的有效位移。3.4 第四步实现场景数据的保存与加载最后我们为RuntimeEditorManager添加保存和加载功能。这里使用Newtonsoft.Json你需要通过Unity的Package Manager或Asset Store安装它。using System.IO; using Newtonsoft.Json; // 需要导入Newtonsoft.Json命名空间 using UnityEngine; public class RuntimeEditorManager : MonoBehaviour { // ... 之前的代码 ... // 保存场景数据到文件 public void SaveScene(string filePath scene_data.json) { SceneData sceneData new SceneData(); foreach (var obj in EditableObjects) { if (obj ! null) { sceneData.Objects.Add(new SceneObjectData(obj)); } } string json JsonConvert.SerializeObject(sceneData, Formatting.Indented); string fullPath Path.Combine(Application.persistentDataPath, filePath); File.WriteAllText(fullPath, json); Debug.Log($场景已保存至: {fullPath}); } // 从文件加载场景数据并应用 public void LoadScene(string filePath scene_data.json) { string fullPath Path.Combine(Application.persistentDataPath, filePath); if (!File.Exists(fullPath)) { Debug.LogWarning($文件不存在: {fullPath}); return; } string json File.ReadAllText(fullPath); SceneData sceneData JsonConvert.DeserializeObjectSceneData(json); // 根据ID查找物体并应用数据 foreach (var objData in sceneData.Objects) { // 注意这里用名字做简单匹配生产环境应用GUID或更稳定的查找方式 GameObject targetObj EditableObjects.Find(o o.name objData.ObjectId); if (targetObj ! null) { objData.ApplyTo(targetObj); } else { Debug.LogWarning($未找到ID为 {objData.ObjectId} 的物体); } } Debug.Log($场景已从 {fullPath} 加载); } // 提供一个按钮调用的方法示例 public void OnSaveButtonClicked() { SaveScene(); } public void OnLoadButtonClicked() { LoadScene(); } }4. 进阶优化与常见问题排查一个基础系统搭建完成后我们通常会遇到一些性能、体验或功能上的问题。下面分享几个关键的优化点和避坑指南。4.1 性能优化要点射线检测优化使用LayerMask这是最重要的优化。确保只有可编辑物体在指定的层如“Editable”中射线检测时只针对这个层可以大幅减少不必要的计算。控制检测频率在Update中每帧进行射线检测是可以的但如果物体非常多可以考虑在鼠标移动时再进行检测或者使用Physics.OverlapSphere等非射线方式进行粗略筛选。简化碰撞体对于复杂模型不要直接使用MeshCollider非凸面时性能开销大而是使用简单的BoxCollider或CapsuleCollider进行包裹。可以在编辑器下编写工具自动为预制件生成合适的简单碰撞体。操控手柄Gizmo优化对象池频繁创建和销毁Gizmo预制件会产生GC垃圾回收压力。可以使用对象池来管理Gizmo实例。按需渲染Gizmo的Renderer组件在未被选中时可以禁用。或者使用Graphics.DrawMesh等更底层的API来绘制性能更好。数据保存优化增量保存如果场景物体很多全量保存可能卡顿。可以记录哪些物体被修改过只保存这些物体的数据。异步保存使用async/await或协程将序列化和文件写入操作放到后台线程避免阻塞主线程导致帧率下降。4.2 坐标系与父子级关系处理这是最容易出bug的地方。问题当你移动一个带有子物体的父物体时如果Gizmo计算位移是基于世界坐标World Space而你的操作意图可能是基于父物体的本地坐标Local Space就会产生混乱。解决方案在GizmoController中提供坐标系切换选项World/Local。计算时需要根据当前坐标系将GetAxisDirection返回的轴向如Vector3.right转换为目标物体本地坐标系或世界坐标系下的方向。Vector3 GetAxisDirectionInTargetSpace(GizmoAxis.AxisType axis, Space space) { if (space Space.World) { return GetAxisDirection(axis); // 世界轴 } else // Local { // 将世界轴方向转换到_targetObject的本地空间 return _targetObject.transform.InverseTransformDirection(GetAxisDirection(axis)); // 注意对于旋转和缩放转换方式可能不同需要仔细处理。 } }实操心得对于初学者建议先实现世界坐标系下的操作逻辑更直观。等核心功能稳定后再添加本地坐标系支持。同时在保存数据时必须明确记录你保存的是本地变换localPosition,localRotation,localScale还是世界变换。通常保存本地变换更通用因为它不受父物体变换的影响。4.3 常见问题排查速查表问题现象可能原因解决方案点击物体没反应无法选中1. 物体没有Collider。2. 物体或摄像机的Layer设置错误射线检测的LayerMask不匹配。3. 有其他UI元素如全屏透明Image挡住了射线。1. 为物体添加碰撞体。2. 检查并统一设置可编辑物体的Layer在InputController中正确设置EditableLayer掩码。3. 检查UI的Raycast Target属性或使用EventSystem.current.IsPointerOverGameObject()在点击时判断是否点在UI上。拖拽时物体移动不跟手、跳动1._dragPlane计算错误导致交点不稳定。2. 物体或Gizmo的更新顺序问题可能在LateUpdate中处理更好。3. 帧率波动导致每帧计算的位移不一致。1. 调试绘制_dragPlane确保其法线方向正确。可以尝试使用垂直于摄像机方向的平面作为通用拖拽平面。2. 将拖拽逻辑放在LateUpdate中确保在摄像机移动后计算。3. 使用Time.deltaTime来平滑移动但对于直接映射鼠标位移的操作通常不需要。旋转操作不直观或卡顿旋转算法过于简单没有将屏幕鼠标位移很好地映射为三维旋转角度。实现更成熟的旋转算法。常用方法是计算鼠标从按下点到当前点在屏幕上的位移向量将其投影到一个虚拟的球体或平面上然后根据这个二维位移计算绕轴旋转的角度。可以参考Unity标准资源包中MouseOrbit或DragRigidbody脚本的思路。保存后再加载物体位置不对1. 保存和加载使用的坐标系不统一世界坐标 vs 本地坐标。2. 用于查找物体的ID如物体名不唯一或发生了变化。1. 确保SceneObjectData的构造函数和ApplyTo方法使用同一种坐标系全部用transform.localPosition或全部用transform.position。2. 不要依赖name为每个EditableObject脚本生成一个唯一的GUIDSystem.Guid.NewGuid().ToString()并在Awake中保存用这个GUID作为ObjectId。多选和批量操作无法实现当前设计只支持单选。维护一个HashSetGameObject _selectedObjects。修改选中逻辑按住Ctrl点击可添加/移除选中。操作时遍历所有选中物体应用相同的变换。Gizmo可以显示在所有选中物体的整体中心bounds.center。4.4 功能扩展建议当你掌握了基础实现后可以考虑添加以下功能让系统更专业撤销/重做Undo/Redo维护一个操作命令栈。每个移动、旋转、缩放操作都封装成一个ICommand对象包含执行Execute()和撤销Undo()方法。操作完成后将命令入栈。这是编辑器级工具的核心功能。吸附功能Snapping拖拽时按住Ctrl键可以让物体的位置、旋转角度或缩放比例吸附到网格或特定增量上。在HandleTranslation等函数的最后对计算结果进行取整或就近吸附计算即可。不同Gizmo样式移动用箭头旋转用圆环缩放用方块。为每种模式准备不同的预制件在SwitchMode时切换。编辑器的集成你可以将RuntimeEditorManager做成一个可配置的Prefab并创建自定义的Editor脚本在Inspector面板上提供方便的按钮来注册场景中所有带EditableObject的物体甚至可视化配置Layer、Gizmo预制件等。这套运行时编辑与保存系统就像给你的应用装上了“编辑器之手”。它剥离了Unity Editor的依赖将创作能力直接交到了用户或玩家手中。从简单的物体摆放到复杂的场景构建其可能性完全由你的想象力决定。实现过程中最关键的不仅是代码本身更是对3D数学向量、平面、坐标变换的理解和对用户体验的细致考量。希望这份详细的指南和源码能成为你实现自己创意功能的坚实起点。如果在实现过程中遇到任何问题欢迎随时交流讨论。