公司动态
Unity Inspector内嵌3D预览器:可视化编辑与PropertyDrawer实战
1. 项目概述为什么我们需要一个Inspector内的3D预览器如果你是一个Unity开发者无论是做游戏、模拟应用还是可视化工具我相信你对这个场景绝不陌生你在Inspector面板里调整一个角色的“武器挂载点”偏移量或者修改一个技能特效的“生成位置”。你不断地在Scene视图和Inspector之间来回切换用鼠标在场景中拖拽那个小小的Gizmo同时眼睛还要瞟着Inspector里那一串不断变化的数字。更头疼的是当模型被其他物体遮挡或者场景过于复杂时精准定位变得异常困难。这种割裂的操作体验不仅效率低下还容易出错。这就是我们今天要解决的问题。与其依赖单一的Scene视图不如直接在Inspector里“再造”一个微型的、可交互的3D预览窗口。想象一下你在调整一个宝箱的“开启角度”参数时旁边就有一个实时的3D宝箱模型你可以直接用鼠标拖拽它旋转直观地看到角度变化同时参数值自动更新。这不仅仅是“方便一点”而是从根本上改变了参数调整的工作流尤其对于美术师、策划或需要频繁调整3D相关数据的团队成员来说体验提升是巨大的。这个功能的核心价值在于将数据编辑场景化、可视化。它把抽象的Vector3、Quaternion数值变成了一个可以直接感知和操作的3D实体。无论是调整光照探针的位置、设置导航网格的边界还是配置一个复杂动画状态机的过渡方向一个内嵌的3D预览器都能让工作变得直观十倍。实现它的技术栈非常纯粹Unity Editor扩展C#加上一点渲染技巧。我们不会用到任何外部插件完全依靠Unity自身的EditorWindow、Handles和EditorGUI系统来构建。下面我就带你从零开始一步步实现这个强大的开发工具。2. 核心原理与架构设计在动手写代码之前我们必须搞清楚这个预览器的运行机制。它不是一个真正的、独立的3D视图而是一个在Inspector的2D布局中模拟3D交互的“视口”。理解这一点至关重要。2.1 Unity Editor GUI的渲染层次Unity的Inspector面板本质上是一个复杂的IMGUI即时模式图形用户界面画布。我们要做的是在这个画布上开辟出一块矩形区域作为我们的“渲染目标”。在这块区域里我们需要完成以下几件事渲染3D模型将指定的GameObject或Mesh绘制到这个2D矩形中。处理输入事件捕获发生在这个矩形区域内的鼠标点击、拖拽事件并将其转化为3D空间中的旋转操作。同步数据将旋转操作的结果实时写回到我们正在Inspector中编辑的脚本的对应字段里。这听起来像是需要自己写一个软渲染器别担心Unity已经为我们提供了强大的工具Handles类和EditorGUIUtility相关的API。我们可以利用Handles.DrawCamera或EditorGUI.DrawPreviewTexture配合一个临时的RenderTexture和Camera来实现3D内容的渲染。2.2 实现方案选型EditorWindowvsPropertyDrawer这里有两个主要的技术路径方案一自定义EditorWindow创建一个独立的浮动窗口。优点是空间大功能可以做得非常复杂如多光源、网格切换等独立性好。缺点是它与Inspector是分离的需要手动关联目标对象操作流程不够无缝。方案二自定义PropertyDrawer这是我们本次选择的方案。PropertyDrawer允许我们为自定义的类或结构体或者通过[Attribute]标记的字段定制其在Inspector中的绘制方式。我们可以直接在字段的位置上绘制出我们的3D预览窗口。这样做的好处是高度集成预览器与它要编辑的数据字段紧紧绑定在一起用户体验最接近原生功能。我们选择方案二。具体来说我们会定义一个[Preview3DAttribute]特性Attribute。创建一个继承自PropertyDrawer的类Preview3DDrawer。在PropertyDrawer.OnGUI方法中绘制预览区域并处理交互。2.3 交互逻辑将2D鼠标位移转换为3D旋转这是整个项目的算法核心。我们在一个2D的屏幕区域预览窗口内拖拽鼠标需要驱动一个3D模型的旋转。最直观的映射方式是模拟“轨迹球”Arcball或“虚拟轨迹球”Virtual Trackball算法。其基本原理是将2D屏幕坐标鼠标位置投影到一个假想的3D球体上。当鼠标拖拽时计算当前帧和上一帧鼠标位置在球体上对应的3D向量。这两个向量的叉积和点积运算可以计算出一个旋转轴和旋转角度即一个Quaternion。将这个旋转增量应用到模型当前的旋转状态上。Unity的Handles类里其实内置了类似的交互处理。我们可以使用Handles.RotationHandle这个函数它会自动在场景中绘制一个旋转Gizmo并处理拖拽事件。但是HandlesAPI默认是在OnSceneGUI中用于场景视图交互的。我们需要一点技巧让它能在OnGUI中为我们所用。一个更直接的方法是我们自己实现一个简化版的轨迹球逻辑这样依赖更少控制也更精细。我们决定采用自制轨迹球算法因为它更轻量且能让我们彻底理解背后的数学原理。下面我们将进入具体的实现环节。3. 一步步实现可拖拽的3D预览器让我们开始编码。首先创建一个新的C#脚本命名为Preview3DAttribute.cs。3.1 第一步创建自定义特性Attribute特性是用来标记字段的元数据。我们的特性非常简单主要用来告诉Unity“嘿这个字段需要在Inspector里用我自定义的方式绘制”。using UnityEngine; public class Preview3DAttribute : PropertyAttribute { // 可以在这里添加一些可配置参数例如预览窗口的大小 public float PreviewHeight { get; private set; } public Preview3DAttribute(float previewHeight 128f) { PreviewHeight Mathf.Max(64f, previewHeight); // 设置一个最小高度 } }这个Preview3DAttribute类继承自PropertyAttribute。它有一个PreviewHeight属性允许使用者在标记字段时指定预览区域的高度比如[Preview3D(150)]我们给了它一个128像素的默认值。3.2 第二步创建自定义PropertyDrawer这是重头戏。创建一个名为Preview3DDrawer.cs的脚本必须将其放在名为Editor的文件夹下可以是任何层级的Editor文件夹否则Unity不会将其识别为编辑器脚本。using UnityEditor; using UnityEngine; [CustomPropertyDrawer(typeof(Preview3DAttribute))] public class Preview3DDrawer : PropertyDrawer { // 为每个字段实例存储其预览模型和旋转状态 private GameObject previewModel; private Quaternion currentRotation Quaternion.identity; private Vector2 lastMousePosition; private bool isDragging false; private PreviewRenderUtility previewRenderUtility; private Material previewMaterial; public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { Preview3DAttribute attr attribute as Preview3DAttribute; // 属性高度 标签行高度 预览区域高度 一些间距 return EditorGUIUtility.singleLineHeight attr.PreviewHeight EditorGUIUtility.standardVerticalSpacing * 2; } public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { // 1. 绘制字段标签 Rect labelRect new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); EditorGUI.LabelField(labelRect, label); // 2. 计算预览区域的矩形 Preview3DAttribute attr attribute as Preview3DAttribute; Rect previewRect new Rect( position.x, position.y EditorGUIUtility.singleLineHeight EditorGUIUtility.standardVerticalSpacing, position.width, attr.PreviewHeight ); // 3. 绘制一个背景框让预览区域视觉上更清晰 EditorGUI.DrawRect(previewRect, new Color(0.1f, 0.1f, 0.1f, 0.8f)); // 4. 核心处理拖拽旋转逻辑与渲染 HandleDragRotation(previewRect); RenderPreview(previewRect, property); // 5. 在预览区下方仍然显示原始的可编辑字段例如Transform引用 Rect propertyFieldRect new Rect( position.x, previewRect.yMax EditorGUIUtility.standardVerticalSpacing, position.width, EditorGUIUtility.singleLineHeight ); EditorGUI.PropertyField(propertyFieldRect, property, GUIContent.none); } }目前我们搭建了基本的框架计算布局、绘制背景。GetPropertyHeight决定了这个字段在Inspector里占多高。OnGUI是主要的绘制逻辑。接下来我们填充最核心的两个方法HandleDragRotation和RenderPreview。注意PreviewRenderUtility是一个强大的编辑器类专门用于在非场景视图如Inspector、自定义窗口中渲染3D内容。它帮你管理好了相机、灯光等一堆琐事。但需要注意它的实例需要被妥善管理和释放否则会引起资源泄漏。我们将在RenderPreview方法中初始化它。3.3 第三步实现鼠标拖拽旋转逻辑轨迹球算法在Preview3DDrawer类中添加HandleDragRotation方法private void HandleDragRotation(Rect previewRect) { Event currentEvent Event.current; int controlID GUIUtility.GetControlID(FocusType.Passive); // 检查鼠标是否在我们的预览区域内 bool isMouseInsidePreview previewRect.Contains(currentEvent.mousePosition); switch (currentEvent.type) { case EventType.MouseDown: if (isMouseInsidePreview currentEvent.button 0) // 左键按下 { isDragging true; lastMousePosition currentEvent.mousePosition; currentEvent.Use(); // 标记事件已处理防止其他UI元素响应 GUIUtility.hotControl controlID; // 设置热点控件 } break; case EventType.MouseUp: if (isDragging currentEvent.button 0) { isDragging false; currentEvent.Use(); GUIUtility.hotControl 0; // 释放热点控件 } break; case EventType.MouseDrag: if (isDragging currentEvent.button 0) { Vector2 currentMousePos currentEvent.mousePosition; Vector2 delta currentMousePos - lastMousePosition; // 核心的轨迹球旋转计算 // 将2D鼠标位移映射到3D球体上的旋转 float rotationSpeed 0.5f; // 旋转灵敏度系数可调整 Vector3 rotationAxis new Vector3(-delta.y, delta.x, 0).normalized; float rotationAngle delta.magnitude * rotationSpeed * Mathf.Deg2Rad; // 应用旋转增量 currentRotation Quaternion.AngleAxis(rotationAngle * Mathf.Rad2Deg, rotationAxis) * currentRotation; lastMousePosition currentMousePos; currentEvent.Use(); GUI.changed true; // 标记GUI已改变促使Inspector重绘 } break; case EventType.Repaint: // 这里暂时什么都不做渲染在另一个方法里 break; } // 如果正在拖拽将鼠标光标改为旋转图标提升用户体验 if (isDragging isMouseInsidePreview) { EditorGUIUtility.AddCursorRect(previewRect, MouseCursor.RotateArrow); } }这段代码实现了基本的鼠标交互。它监听了鼠标在预览区域内的按下、拖拽和抬起事件。拖拽时根据鼠标移动的delta值计算出一个旋转轴和角度然后更新currentRotation。这里的映射算法new Vector3(-delta.y, delta.x, 0)是一个经典的简化版轨迹球映射垂直拖动绕X轴旋转水平拖动绕Y轴旋转符合大多数3D软件的交互直觉。3.4 第四步实现3D模型的渲染现在实现最激动人心的部分把模型画出来。添加RenderPreview方法和相关的辅助方法。private void RenderPreview(Rect previewRect, SerializedProperty property) { if (previewRenderUtility null) { InitializePreviewRenderUtility(); } // 从序列化属性中获取需要预览的GameObject或Transform GameObject targetGameObject GetTargetGameObject(property); if (targetGameObject null) { // 如果字段为空绘制一个提示信息 EditorGUI.DropShadowLabel(previewRect, Drag a GameObject here); return; } // 确保我们有一个用于预览的模型实例 SetupPreviewModel(targetGameObject); // 配置预览相机 Camera cam previewRenderUtility.camera; cam.transform.position Vector3.back * 5; // 相机放在模型前方 cam.transform.rotation Quaternion.identity; cam.nearClipPlane 0.1f; cam.farClipPlane 50f; // 让相机看向模型并自动调整距离确保模型完整在视野内 Bounds bounds CalculateBounds(previewModel); FrameCameraToBounds(cam, bounds, previewRect); // 应用我们通过鼠标拖拽计算出的旋转 previewModel.transform.rotation currentRotation; // 设置环境光让模型看起来更自然 previewRenderUtility.lights[0].intensity 0.8f; previewRenderUtility.lights[0].transform.rotation Quaternion.Euler(30f, 30f, 0); previewRenderUtility.ambientColor new Color(0.2f, 0.2f, 0.2f, 1.0f); // 关键一步将3D场景渲染到一张临时纹理并绘制到我们的previewRect上 previewRenderUtility.BeginPreview(previewRect, GUIStyle.none); previewRenderUtility.Render(); Texture previewTexture previewRenderUtility.EndPreview(); GUI.DrawTexture(previewRect, previewTexture, ScaleMode.StretchToFill, false); } private void InitializePreviewRenderUtility() { if (previewRenderUtility ! null) { previewRenderUtility.Cleanup(); } previewRenderUtility new PreviewRenderUtility(); previewRenderUtility.camera.fieldOfView 30f; // 一个舒适的视野 // 创建一个简单的无光照材质用于预览网格 previewMaterial new Material(Shader.Find(Unlit/Color)); previewMaterial.color Color.white; } private GameObject GetTargetGameObject(SerializedProperty property) { // 支持序列化属性指向GameObject或Component如Transform if (property.propertyType SerializedPropertyType.ObjectReference) { UnityEngine.Object obj property.objectReferenceValue; if (obj is GameObject go) return go; if (obj is Component comp) return comp.gameObject; } return null; } private void SetupPreviewModel(GameObject source) { // 如果预览模型不存在或者源对象变了就重新创建 if (previewModel null || previewModel.name ! source.name _Preview) { CleanupPreviewModel(); previewModel GameObject.Instantiate(source); previewModel.name source.name _Preview; previewModel.hideFlags HideFlags.HideAndDontSave; // 重要防止它出现在场景层级中 // 移除所有不需要的组件如碰撞体、刚体、脚本等纯渲染用 var allComponents previewModel.GetComponentsInChildrenComponent(); foreach (var comp in allComponents) { if (!(comp is Transform) !(comp is MeshFilter) !(comp is SkinnedMeshRenderer) !(comp is Renderer)) { GameObject.DestroyImmediate(comp); } } // 应用一个简单的材质确保渲染一致 var renderers previewModel.GetComponentsInChildrenRenderer(); foreach (var renderer in renderers) { renderer.sharedMaterial previewMaterial; } } } private Bounds CalculateBounds(GameObject obj) { Bounds bounds new Bounds(obj.transform.position, Vector3.zero); Renderer[] renderers obj.GetComponentsInChildrenRenderer(); foreach (Renderer r in renderers) { bounds.Encapsulate(r.bounds); } if (bounds.size Vector3.zero) bounds.size Vector3.one; // 防止零边界 return bounds; } private void FrameCameraToBounds(Camera cam, Bounds bounds, Rect viewportRect) { // 计算使边界球完全在相机视野内所需的距离 float objectSize Mathf.Max(bounds.size.x, bounds.size.y, bounds.size.z); float cameraDistance objectSize / (2.0f * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad)); // 考虑视口宽高比取最保守最大的距离 float aspect viewportRect.width / viewportRect.height; float distanceV objectSize / (2.0f * Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad)); float distanceH objectSize / (2.0f * Mathf.Tan(Mathf.Atan(Mathf.Tan(cam.fieldOfView * 0.5f * Mathf.Deg2Rad) * aspect))); cameraDistance Mathf.Max(distanceV, distanceH) * 1.1f; // 加10%边距 Vector3 cameraPosition bounds.center - Vector3.forward * cameraDistance; cam.transform.position cameraPosition; cam.transform.LookAt(bounds.center); } private void CleanupPreviewModel() { if (previewModel ! null) { GameObject.DestroyImmediate(previewModel); previewModel null; } } // 非常重要必须释放PreviewRenderUtility占用的资源 void OnDisable() { if (previewRenderUtility ! null) { previewRenderUtility.Cleanup(); previewRenderUtility null; } CleanupPreviewModel(); if (previewMaterial ! null) { GameObject.DestroyImmediate(previewMaterial); previewMaterial null; } }RenderPreview方法做了以下几件关键事情初始化渲染工具首次调用时创建PreviewRenderUtility它内置了相机和灯光。获取目标对象从Inspector正在编辑的字段中拿到用户拖入的GameObject或Component。创建预览实例实例化一个该对象的拷贝并剥离所有非渲染相关的组件脚本、碰撞体等确保它只是一个用于显示的“壳”。这里使用了HideFlags.HideAndDontSave防止这个临时对象污染你的项目。配置相机根据预览模型的包围盒Bounds自动调整相机的位置和视角确保模型完整且居中地显示在预览窗口内。这是提升用户体验的关键无论模型大小都能获得良好的初始视图。应用旋转将我们通过鼠标拖拽计算出的currentRotation应用到预览模型上。渲染与绘制使用previewRenderUtility将3D场景渲染到一张纹理然后通过GUI.DrawTexture将这张纹理画到我们指定的previewRect矩形里。3.5 第五步在MonoBehaviour中使用现在让我们创建一个测试脚本来使用这个功能。using UnityEngine; public class MyComponent : MonoBehaviour { [Header(3D Preview Demo)] [Preview3D(160)] // 使用特性并设置预览高度为160像素 public Transform weaponAttachmentPoint; [Preview3D] // 使用默认高度 public GameObject treasureChest; [Range(0, 360)] public float chestOpenAngle; }将MyComponent脚本挂载到任意场景物体上。在Inspector中你会看到weaponAttachmentPoint和treasureChest字段不再是普通的对象选择框而是变成了一个带有可拖拽旋转3D预览的复合控件。你可以直接将场景中的物体拖到这两个字段上然后直接在预览窗口中用鼠标旋转它们直观地进行观察和定位。4. 高级功能扩展与性能优化基础功能已经实现但一个健壮的工具还需要考虑更多。下面我们来添加一些提升实用性和稳定性的功能。4.1 添加重置视图与自适应背景功能在预览区域添加一个右键菜单或一个小按钮用于重置旋转和自适应视图非常实用。首先在Preview3DDrawer.OnGUI方法中绘制预览区域后添加一个重置按钮。// 在RenderPreview调用之后OnGUI方法内添加 Rect resetButtonRect new Rect(previewRect.xMax - 60, previewRect.y 5, 55, 20); if (GUI.Button(resetButtonRect, Reset View)) { currentRotation Quaternion.identity; // 如果需要也可以在这里重置相机位置 GUI.changed true; }然后我们可以改进背景根据模型的主色调或环境自动调整或者添加一个网格地面作为参考。修改RenderPreview方法中的背景设置部分// 在previewRenderUtility.BeginPreview之前可以设置纯色背景或渐变色 // 例如设置一个渐变的天空盒色简单模拟 previewRenderUtility.camera.clearFlags CameraClearFlags.Color; previewRenderUtility.camera.backgroundColor EditorGUIUtility.isProSkin ? new Color(0.2f, 0.2f, 0.2f, 1f) : new Color(0.8f, 0.8f, 0.8f, 1f); // 可选在场景中添加一个简单的参考网格或平面 // 注意PreviewRenderUtility的场景是独立的需要手动创建并管理这些辅助物体添加参考网格需要创建一个简单的网格如一个平面或一个网格Gizmo并添加到previewRenderUtility的场景中。为了保持代码清晰这里暂不展开但思路是在InitializePreviewRenderUtility中创建网格并确保其随相机移动或保持在世界中心。4.2 支持更多数据类型与复杂模型目前的实现只支持GameObject或Component类型的字段。我们可以扩展它使其支持直接预览Mesh、Material甚至Sprite显示为3D平面。我们需要修改GetTargetGameObject和SetupPreviewModel方法。可以创建一个通用的SetupPreview方法根据属性类型创建不同的预览物体。private void SetupPreview(SerializedProperty property) { CleanupPreviewModel(); UnityEngine.Object obj property.objectReferenceValue; if (obj is Mesh mesh) { previewModel GameObject.CreatePrimitive(PrimitiveType.Cube); // 临时用Cube替换其Mesh previewModel.hideFlags HideFlags.HideAndDontSave; previewModel.GetComponentMeshFilter().sharedMesh mesh; var renderer previewModel.GetComponentRenderer(); renderer.sharedMaterial previewMaterial; } else if (obj is Material mat) { previewModel GameObject.CreatePrimitive(PrimitiveType.Sphere); previewModel.hideFlags HideFlags.HideAndDontSave; previewModel.GetComponentRenderer().sharedMaterial mat; } // ... 其他类型的处理 else { // 原有的GameObject/Component处理逻辑 GameObject targetGo GetTargetGameObject(property); if (targetGo ! null) { SetupPreviewModel(targetGo); // 调用原有的复杂模型处理逻辑 } } }同时对于复杂的角色模型带骨骼的SkinnedMeshRenderer我们的CalculateBounds方法可能需要一帧后才能获取正确的包围盒因为蒙皮网格的边界是动态计算的。一个解决办法是在SetupPreviewModel后强制SkinnedMeshRenderer更新一次SkinnedMeshRenderer smr previewModel.GetComponentInChildrenSkinnedMeshRenderer(); if (smr ! null) { smr.updateWhenOffscreen true; // 确保离屏也更新 // 可能需要等待一帧但在Editor脚本中我们可以直接计算 // 一个简单的方法是手动遍历所有顶点计算边界性能消耗大慎用 }4.3 性能优化与资源管理这是编辑器扩展的重中之重处理不好会导致内存泄漏和编辑器卡顿。对象池化如果Inspector频繁刷新如选中多个对象反复实例化和销毁previewModel和previewRenderUtility开销很大。可以考虑使用一个简单的对象池或者使用EditorGUIUtility.GetTemporaryPreviewRenderUtility如果存在。但我们的实现中每个PropertyDrawer实例管理自己的资源在OnDisable中清理对于单个字段的预览是合理的。按需渲染OnGUI在每一帧都会被调用多次。我们应该只在必要时才进行昂贵的3D渲染即EventType.Repaint事件时。我们的代码已经将渲染逻辑放在了RenderPreview中而RenderPreview只在OnGUI中调用一次。但更精细的控制是public override void OnGUI(...) { // ... 布局和事件处理 if (Event.current.type EventType.Repaint) { RenderPreview(previewRect, property); } // ... 绘制其他GUI元素 }确保RenderPreview只在重绘事件时执行。释放资源OnDisable方法我们已经实现了它会在PropertyDrawer不再需要时如脚本被禁用、编辑器退出时被调用。务必确保PreviewRenderUtility.Cleanup()和DestroyImmediate被正确执行。预览模型复用当同一个字段的值没有变化时不应该重新创建预览模型。我们已经在SetupPreviewModel中通过名字做了简单判断更严谨的做法是比较对象的实例ID。5. 常见问题排查与实战技巧在实际使用和扩展这个预览器的过程中你肯定会遇到一些坑。这里我把自己踩过的雷和解决方案分享给你。5.1 预览窗口一片黑或显示不正确这是最常见的问题。请按以下步骤排查检查目标对象确保你拖入字段的GameObject或Component是有效的并且其下包含MeshFilterMeshRenderer或SkinnedMeshRenderer。空对象或只有脚本的对象是无法渲染的。检查PreviewRenderUtility初始化在InitializePreviewRenderUtility中确认相机和灯光已创建。可以在初始化后打印previewRenderUtility.camera和previewRenderUtility.lights的信息来调试。检查渲染流程在RenderPreview方法中在previewRenderUtility.Render()前后添加Debug.Log确认渲染被正常调用。确保GUI.DrawTexture的矩形previewRect是正确的。检查材质我们强制使用了Unlit/Color着色器。如果原模型使用了非常复杂的表面着色器如Standard Shader且依赖法线、光照贴图等在简单的无光照材质下可能会显示为黑色或紫色。一个更健壮的方法是尝试使用原模型的材质或者使用Unity内置的EditorGUIUtility.GetBuiltinExtraResource中的“Default Diffuse”材质。if (previewMaterial null) { // 尝试使用更兼容的默认材质 previewMaterial new Material(Shader.Find(Standard)); // 或者使用内置的默认漫反射材质 // previewMaterial EditorGUIUtility.GetBuiltinExtraResourceMaterial(Default-Diffuse.mat); }视锥体裁剪模型可能不在相机视锥体内。仔细检查FrameCameraToBounds函数计算出的cameraDistance和cameraPosition。可以在计算后添加Debug.DrawLine在场景视图中或打印日志来验证相机是否正对模型中心。5.2 鼠标拖拽旋转不跟手或方向奇怪这通常是轨迹球映射算法参数需要调整。旋转速度rotationSpeed变量在HandleDragRotation中控制旋转灵敏度。如果感觉太快或太慢调整这个值例如从0.5f改为0.3f或0.8f。旋转轴映射公式new Vector3(-delta.y, delta.x, 0)决定了垂直拖动对应X轴旋转水平拖动对应Y轴旋转。如果你希望反转某个轴例如希望垂直拖动时模型向上旋转而不是向下将-delta.y改为delta.y即可。滚轮缩放你可以轻松扩展功能监听EventType.ScrollWheel事件通过改变相机距离或fieldOfView来实现缩放。case EventType.ScrollWheel: if (isMouseInsidePreview) { float zoomSpeed 0.5f; // 这里以调整相机距离为例 // 需要将相机距离存储为一个变量例如 float cameraDistanceOffset cameraDistanceOffset currentEvent.delta.y * zoomSpeed; currentEvent.Use(); GUI.changed true; } break;5.3 多实例同时预览时的冲突我们的Preview3DDrawer类中previewModel、currentRotation等字段是实例变量。但当你在Inspector中同时查看多个包含[Preview3D]字段的组件时或者同一个组件被多个Inspector窗口编辑时Unity可能会复用同一个PropertyDrawer实例导致状态串扰。解决方案使用序列化字段或EditorGUIUtility.GetControlID来生成唯一键将状态存储在全局的字典中以属性路径和实例ID作为键。这是一个更高级的用法可以保证每个预览窗口的状态完全独立。private static Dictionarystring, Quaternion rotationStates new Dictionarystring, Quaternion(); private string GetStateKey(SerializedProperty property) { return ${property.serializedObject.targetObject.GetInstanceID()}_{property.propertyPath}; } // 在需要获取或设置currentRotation时 string key GetStateKey(property); if (!rotationStates.ContainsKey(key)) rotationStates[key] Quaternion.identity; currentRotation rotationStates[key]; // ... 旋转更新后 rotationStates[key] currentRotation;5.4 在Inspector数组或列表中使用默认的PropertyDrawer可以用于数组中的元素。但是你需要确保在GetPropertyHeight和OnGUI中正确处理每个元素的位置。我们的现有实现已经支持这一点因为position参数是由Unity自动计算好的每个元素的位置。一个需要注意的问题是当数组折叠时其内部的PropertyDrawer不会被调用所以不会产生性能开销。只有当数组展开时才会为每个元素创建预览。这符合预期。5.5 实战技巧与数值字段联动我们的预览器目前是只读的视觉辅助。一个更高级的用法是让它能够反向修改脚本上的其他数值字段。例如旋转预览模型时自动更新同一个脚本上的一个Vector3或Quaternion类型的“旋转角度”字段。这需要在HandleDragRotation中在更新currentRotation的同时找到目标脚本和字段并使用SerializedObject和SerializedProperty来修改其值。// 在HandleDragRotation中旋转更新后 if (GUI.changed) { SerializedObject so property.serializedObject; // 假设你的脚本上有一个名为targetRotation的Quaternion字段 SerializedProperty rotationProp so.FindProperty(targetRotation); if (rotationProp ! null) { rotationProp.quaternionValue currentRotation; so.ApplyModifiedProperties(); // 关键应用修改 } }这样可视化编辑就与数据字段真正绑定起来了实现了“所见即所得”的编辑体验。这个3D预览器虽然代码量不大但涉及了Unity编辑器扩展的多个核心概念PropertyDrawer、PreviewRenderUtility、Handles/GUI事件处理、序列化属性操作以及简单的3D数学。理解并掌握它你就能打开一扇新的大门创造出无数提升自己和团队工作效率的定制化工具。从调整一个简单的偏移量到配置复杂的效果参数让开发过程变得更加直观和愉悦。