1. 项目概述:从概念到实践的桥梁
如果你正在用Unity3D做数字孪生项目,并且已经过了看概念、搭框架的阶段,那么你大概率会遇到一个核心问题:那些炫酷的实时数据驱动、模型动态更新、虚实同步交互,到底是怎么一行行代码敲出来的?市面上的教程要么太浅,只讲UI拖拽;要么太玄,大谈架构哲学。真正把Unity里那些关键API,比如Transform、Mesh、MaterialPropertyBlock、Shader,还有各种数据解析和网络通信,如何拧成一股绳,实现一个稳定可用的数字孪生核心逻辑,却很少有人掰开揉碎了讲。这篇笔记,就是来解决这个痛点的。它不打算覆盖数字孪生的全貌,而是聚焦于“实战”——当你手里有一个BIM或CAD导出的模型,有一串从物联网平台或数据库里吐出来的实时数据,你该如何用Unity的API把它们生动地呈现并互动起来。这适合已经熟悉Unity基本操作和C#语法,正准备或正在涉足工业仿真、智慧城市、设备运维等数字孪生应用领域的开发者。我们将避开纯理论,直接进入代码层面,看看API该怎么调,参数该怎么设,以及背后那些容易踩坑的细节。
2. 核心API实战:数据驱动与模型操控
数字孪生的灵魂是“动态”,静态模型展示用任何三维软件都能做。Unity的价值在于它能实时响应数据变化,并驱动场景中的元素做出相应改变。这一部分,我们深入几个最核心的API组合。
2.1 动态数据绑定与UI更新
数据来了,首先要可视化。除了在3D物体上体现,仪表盘、数据面板等2D UI也是重要组成部分。这里UGUI的Text、Image和Slider等组件是基础,但高效更新是关键。
核心API:Text.text,Image.fillAmount,Slider.value直接赋值是最简单的方式,但频繁更新需考虑性能。对于高频数据(如每秒多次的传感器读数),避免每帧都直接调用text = value.ToString(),这会产生大量字符串垃圾。一个实用的优化模式是使用一个协程或基于时间的更新间隔。
public class DataDashboard : MonoBehaviour { public Text temperatureText; public Image pressureFill; public Slider rpmSlider; private float updateInterval = 0.2f; // 每秒更新5次,而非每帧 private float timer; private float currentTemperature; private float currentPressure; private float currentRPM; void Update() { timer += Time.deltaTime; if (timer >= updateInterval) { timer = 0f; // 假设从某个数据管理器获取最新值 UpdateUIElements(); } } void UpdateUIElements() { // 使用StringBuilder或直接赋值,但控制频率 temperatureText.text = $“{currentTemperature:F1} °C”; // F1保留一位小数 pressureFill.fillAmount = currentPressure / 100f; // 假设压力范围0-100 rpmSlider.value = currentRPM; } // 供外部数据源调用,更新原始数据 public void UpdateData(float temp, float pressure, float rpm) { currentTemperature = temp; currentPressure = pressure; currentRPM = rpm; // 不在这里直接更新UI,由定时器统一处理 } }实操心得:
- 数据与表现分离:
UpdateData方法只更新内部变量,UI的刷新由另一个节奏控制。这解耦了数据接收(可能很快)和界面渲染(需要稳定帧率)。 - 格式化字符串开销:对于极度频繁的更新,可以考虑将数字拆解为整数和小数部分分别更新到不同的
Text组件,或者使用StringBuilder缓存格式化的字符串,但需要权衡代码复杂度与收益。对于大多数工业场景,0.2秒的间隔已经足够流畅且性能友好。 - DO Tween动画:如果数据变化需要平滑过渡(如指针转动、填充动画),可以结合使用DoTween插件。例如,
rpmSlider.DOValue(targetRPM, 0.5f).SetEase(Ease.OutQuad),能让变化更自然,避免突兀跳变。
2.2 模型变换与层级控制
设备移动、阀门旋转、吊臂升降,这些动作本质上是物体Transform组件的改变。高效、准确地控制Transform是基础。
核心API:Transform.localPosition,Transform.localRotation,Transform.SetParent直接赋值transform.localPosition = new Vector3(x, y, z)是最直接的。但对于需要插值动画的移动,应使用Vector3.Lerp或Transform.Translate。更关键的是层级管理。一个复杂的设备可能由底座、旋转台、机械臂等多层结构组成。
public class EquipmentController : MonoBehaviour { public Transform rotationBase; // 旋转底座 public Transform armSegment; // 机械臂段 public float baseRotationSpeed = 10f; public float armExtensionSpeed = 1f; private float targetBaseAngle; private float targetArmLength; void Update() { // 平滑旋转底座 Quaternion targetRotation = Quaternion.Euler(0, targetBaseAngle, 0); rotationBase.localRotation = Quaternion.Slerp(rotationBase.localRotation, targetRotation, Time.deltaTime * baseRotationSpeed); // 平滑伸缩机械臂(假设沿本地Z轴伸缩) Vector3 currentScale = armSegment.localScale; currentScale.z = Mathf.Lerp(currentScale.z, targetArmLength, Time.deltaTime * armExtensionSpeed); armSegment.localScale = currentScale; } public void SetJointAngles(float baseAngle, float armLength) { targetBaseAngle = baseAngle; targetArmLength = armLength; } // 动态改变父子关系示例:抓取一个物体 public void AttachToTool(Transform objectToAttach) { objectToAttach.SetParent(armSegment); // 设置父节点为机械臂末端 objectToAttach.localPosition = Vector3.zero; // 重置局部位置到末端中心 objectToAttach.localRotation = Quaternion.identity; // 注意:这会改变物体的世界坐标,但相对于机械臂末端保持固定 } }注意事项:
localvsglobal:在层级结构中,始终优先使用localPosition和localRotation,除非你明确需要世界空间坐标。这能保证你的操作是基于父物体坐标系的,更符合机械结构的逻辑。SlerpvsLerp:旋转插值使用Quaternion.Slerp(球面线性插值)比Lerp更准确,能保证旋转路径最短且速度均匀。位置插值用Vector3.Lerp即可。SetParent的第二个参数:SetParent(parent, worldPositionStays)。通常,在动态装配场景中,我们希望被抓取的物体在世界空间的位置保持不变,直到父物体移动它。这时应使用SetParent(newParent, true)。如果希望立即与父物体局部坐标系对齐,则用SetParent(newParent, false),后接重置局部变换的代码。
2.3 材质属性动态修改与高亮反馈
根据设备状态(运行、报警、停机)改变颜色、透明度或发光强度,是数字孪生常见的视觉反馈。直接创建多个材质实例(Material Instance)是一种方法,但对于成百上千个相同物体,这会造成巨大的Draw Call和内存开销。正确的做法是使用MaterialPropertyBlock。
核心API:MaterialPropertyBlock,Renderer.SetPropertyBlockMaterialPropertyBlock允许你覆盖渲染器上材质的某些属性,而无需创建新的材质实例。
public class StatusIndicator : MonoBehaviour { private Renderer targetRenderer; private MaterialPropertyBlock propertyBlock; public Color normalColor = Color.gray; public Color warningColor = Color.yellow; public Color alarmColor = Color.red; void Start() { targetRenderer = GetComponent<Renderer>(); propertyBlock = new MaterialPropertyBlock(); // 先获取当前渲染器已有的属性块(如果有),避免覆盖其他属性 targetRenderer.GetPropertyBlock(propertyBlock); } public void SetStatus(EquipmentStatus status) { Color targetColor = normalColor; switch (status) { case EquipmentStatus.Normal: targetColor = normalColor; break; case EquipmentStatus.Warning: targetColor = warningColor; break; case EquipmentStatus.Alarm: targetColor = alarmColor; break; } // 设置属性块中的颜色属性。假设Shader中有一个名为“_BaseColor”的属性。 propertyBlock.SetColor(“_BaseColor”, targetColor); // 也可以同时设置其他属性,如自发光强度 propertyBlock.SetFloat(“_EmissionIntensity”, status == EquipmentStatus.Alarm ? 2.0f : 0.0f); // 将属性块应用到此渲染器 targetRenderer.SetPropertyBlock(propertyBlock); } } public enum EquipmentStatus { Normal, Warning, Alarm }核心原理与避坑指南:
- 性能优势:即使场景中有1000个使用同一材质的设备,通过
MaterialPropertyBlock修改颜色,它们仍然共享同一个材质资源,GPU Instancing仍然可能生效,极大地提升了渲染效率。而如果每个都material.color = newColor,就会创建1000个材质实例,破坏合批。 - Shader属性名必须匹配:
SetColor(“_BaseColor”, color)中的“_BaseColor”必须与你的Shader中定义的属性名完全一致(包括下划线)。通常可以在材质的Inspector面板上看到这些属性名。一个常见的错误是使用了错误的属性名导致修改无效。 GetPropertyBlock的用途:在修改前先调用GetPropertyBlock,是为了合并修改。如果不获取,新的PropertyBlock会完全替换旧的,可能会丢失其他脚本设置的属性(比如另一个脚本设置了的纹理)。这是一个良好的实践。- 动态批处理限制:使用
MaterialPropertyBlock后,物体将无法进行动态批处理(Dynamic Batching),但对于静态或GPU Instancing,影响不大。在数字孪生中,物体通常较多且状态独立,GPU Instancing是更重要的优化手段,而PropertyBlock与Instancing兼容良好。
3. 外部数据接入与解析实战
数字孪生不是一个封闭的3D场景,它需要呼吸真实世界的数据。这部分是连接虚与实的关键,也是最容易出问题的地方。
3.1 常见数据源与通信方式
根据项目规模和数据实时性要求,接入方式各异:
- HTTP/HTTPS (RESTful API):最通用。用于从云平台、数据中台或本地服务器获取非实时或准实时数据(如每分钟更新的温度、每日报表)。使用
UnityWebRequest或第三方库(如RestClient)。 - WebSocket:双向、全双工、低延迟通信。适用于高实时性场景,如设备控制指令下发、秒级刷新的传感器数据。Unity官方没有内置WebSocket客户端,需要使用第三方库(如
websocket-sharp、NativeWebSocket)。 - MQTT:轻量级的发布/订阅消息协议,在物联网领域极为流行。设备作为客户端发布数据到Broker,Unity客户端订阅相关主题即可接收。有成熟的Unity Asset Store插件(如
MQTT4Unity)。 - TCP/UDP Socket:最底层、最灵活的通信方式,常用于与特定硬件或私有协议对接。需要自己处理封包、拆包、粘包等问题,复杂度高。
- 数据库直连:较少见,通常不推荐在Unity客户端直连生产数据库(安全、性能问题)。可用于原型验证或读取静态配置数据,可使用
SQLite(本地轻量数据库)或通过ORM连接其他数据库。
3.2 使用UnityWebRequest处理REST API数据
假设我们从某个工业物联网平台获取一台泵的实时数据,API返回JSON格式。
using UnityEngine; using UnityEngine.Networking; using System.Collections; public class IoTDataFetcher : MonoBehaviour { public string apiUrl = “http://your-iot-platform/api/v1/device/001/status”; public string authToken = “your-bearer-token”; public float pollingInterval = 2.0f; // 轮询间隔 private PumpData currentPumpData; IEnumerator Start() { while (true) { yield return StartCoroutine(FetchPumpData()); yield return new WaitForSeconds(pollingInterval); } } IEnumerator FetchPumpData() { using (UnityWebRequest request = UnityWebRequest.Get(apiUrl)) { // 设置认证头(如果是Bearer Token) request.SetRequestHeader(“Authorization”, “Bearer “ + authToken); // 设置接受JSON的Header request.SetRequestHeader(“Accept”, “application/json”); yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { string jsonText = request.downloadHandler.text; ParsePumpData(jsonText); } else { Debug.LogError($“API请求失败: {request.error}”); // 这里可以触发一个UI警告,或者使用上一次的有效数据 } } } void ParsePumpData(string json) { try { // 使用Unity自带的JsonUtility或第三方库如Newtonsoft.Json // 假设有一个PumpData类与JSON结构对应 currentPumpData = JsonUtility.FromJson<PumpData>(json); Debug.Log($“泵状态更新: 转速={currentPumpData.rpm}, 温度={currentPumpData.temperature}”); // 通知其他组件数据已更新 EventManager.Instance?.TriggerPumpDataUpdated(currentPumpData); } catch (System.Exception e) { Debug.LogError($“解析JSON失败: {e.Message}”); } } } [System.Serializable] public class PumpData { public string deviceId; public float rpm; public float temperature; public float pressure; public bool isRunning; }关键点与排查技巧:
- 使用
using语句:确保UnityWebRequest对象在使用后被正确销毁,避免内存泄漏。 - 错误处理必须完备:网络请求可能因为各种原因失败(超时、404、500、证书错误)。
request.result提供了清晰的枚举状态(Success, ConnectionError, ProtocolError等)。必须对每种错误有相应的处理逻辑,比如重试、降级显示、用户提示。 - 主线程操作:
UnityWebRequest在协程中运行,但JsonUtility.FromJson和后续的数据处理是主线程安全的。如果你要更新UI或场景物体,可以直接操作。如果使用第三方JSON库,注意线程安全。 - 轮询间隔设置:轮询太频繁会给服务器带来压力,太慢则失去实时性。需要根据业务需求平衡。对于控制指令,应采用WebSocket实现服务器主动推送。
- 认证与安全:切勿将Token、API Key等敏感信息硬编码在代码中或提交到版本库。应使用Unity的
PlayerPrefs(不安全,仅用于原型)、环境变量,或通过一个安全的登录流程从服务器动态获取。
3.3 处理大数据量模型与LOD
数字孪生场景常常包含由BIM或CAD软件导出的大型、高精度模型,直接导入Unity可能导致帧率骤降。这时,Level of Detail(LOD)技术至关重要。Unity原生支持LOD Group组件。
核心API:LODGroup,Renderer.enabled但手动为每个复杂物体设置LOD Group很繁琐。更实用的实战流程是:
- 在建模软件中生成LOD:使用3ds Max、Blender等软件的插件或功能,为原始高模生成中、低精度版本。通常需要3-4个级别(LOD0: 100%面数, LOD1: 50%, LOD2: 25%, LOD3: 10%或一个Billboard)。
- 导出为FBX:将每个LOD级别的模型分别导出,或导出包含多个Mesh的FBX文件。
- 在Unity中配置LOD Group:
- 将最高精度的模型(LOD0)拖入场景。
- 选中它,在Inspector中点击
Add Component->Rendering->LOD Group。 - 在LOD Group组件中,你会看到默认的LOD0(一个Renderer列表)。点击
Add按钮添加LOD1、LOD2等。 - 将不同精度的模型(可以是Prefab或场景中的物体)拖拽到对应LOD级别的
Renderers列表中。 - 调整每个LOD级别的屏幕相对高度(百分比)。例如,LOD0(100%模型)在屏幕高度大于10%时显示;LOD1在5%-10%时显示;LOD2在2%-5%时显示;LOD3(Billboard)在小于2%时显示。
实操心得:
- LOD切换距离的校准:不要凭感觉设置百分比。最好的方法是在游戏运行状态下,在Scene视图打开
LOD Gizmos(通常在下拉菜单中),然后移动摄像机,观察不同距离下LOD的切换是否平滑、是否出现“ popping”(突然切换)。反复调整百分比直到切换不易被察觉。 - 合并材质与着色器:确保所有LOD级别的模型使用相同或尽可能相似的材质和Shader。如果LOD0使用复杂PBR材质,而LOD3使用一个简单的Unlit Shader,切换时会有明显的视觉跳跃。
- 对于超大规模场景:仅靠Per-Object的LOD Group不够,还需要结合遮挡剔除(Occlusion Culling)和地形LOD系统。Unity的静态合批(Static Batching)与LOD Group有时冲突,需要测试。对于动态物体,可以考虑使用脚本根据距离动态启用/禁用子物体或切换Mesh,实现自定义LOD逻辑。
- Billboard生成:对于远处的树木、电线杆等,可以使用一个始终面向摄像机的四边形(Quad),并贴上该物体的简化纹理。Unity的Tree Creator系统或一些资产商店插件可以自动处理这个。
4. 性能优化与问题排查实录
数字孪生项目很容易遇到性能瓶颈,尤其是在中低端硬件或WebGL平台上。以下是一些实战中总结出的核心优化点和排查方法。
4.1 CPU与GPU性能瓶颈分析
首先,你要能定位问题出在哪里。Unity Profiler (Window > Analysis > Profiler) 是你的第一工具。
- CPU瓶颈:在Profiler的CPU Usage区域,查看主线程(
Main Thread)和渲染线程(Render Thread)的时间。如果Main Thread耗时很高,可能是复杂的脚本逻辑、过多的GameObject.Update调用、频繁的Instantiate/Destroy或复杂的物理计算。如果Render Thread高,通常是Draw Call太多或GPU指令复杂。 - GPU瓶颈:在Profiler的GPU区域查看耗时。也可以使用
RenderDoc或Intel GPA等外部工具进行更深入的GPU分析。GPU瓶颈通常表现为高填充率(过度绘制)、复杂Shader或高分辨率纹理。
常见问题与优化策略表:
| 问题现象 | 可能原因 | 优化策略 |
|---|---|---|
| 移动/旋转物体时卡顿 | 每帧更新的物体太多,脚本效率低。 | 1. 使用Object Pool复用物体,避免Instantiate/Destroy。2. 将不需要每帧更新的脚本 Update改为在固定间隔或事件驱动下执行。3. 使用 Job System或Burst Compiler并行处理大量物体的位置计算(适用于Unity DOTS)。 |
| 场景静止时帧率依然低 | Draw Call过高,材质实例过多,实时灯光/阴影过多。 | 1.静态合批(Static Batching):标记不动的物体为Static,Unity会将其合并。2.GPU Instancing:对使用相同材质和Mesh的物体启用GPU Instancing(在材质球上勾选)。与 MaterialPropertyBlock兼容。3.减少实时灯光:使用烘焙光照(Lightmapping)和光照探针(Light Probes)替代。 4.简化阴影:降低阴影分辨率、使用较近的阴影距离、对远处物体禁用阴影。 |
| UI界面复杂导致卡顿 | Canvas重建开销大,特别是包含大量动态变化的UI元素。 | 1.分离Canvas:将静态UI和动态UI放在不同的Canvas下。一个Canvas下的任一元素变化都会导致整个Canvas重建。 2.使用 RectMask2D:替代Mask组件,性能更好。3.避免频繁改变UI布局(如大小、位置),这会触发重建。 |
| 加载大型模型时卡死 | 同步加载阻塞主线程。 | 1.异步加载:使用Addressables异步加载系统或Resources.LoadAsync。2.分帧加载:将模型拆分成多个部分,分几帧加载完成。 3.显示加载进度条,提升用户体验。 |
| WebGL版本运行缓慢 | WebGL是单线程,且性能开销与原生有差距。 | 1. **启用IL2CPP**代码生成,而非Mono。2.大幅降低Draw Call和面数目标。 3.压缩纹理,使用ASTC或ETC2格式。 4.谨慎使用反射、字符串操作等托管代码中开销大的操作。 |
4.2 内存管理与泄漏排查
数字孪生项目往往需要长时间运行,内存泄漏会导致崩溃。
- 检查工具:Unity Profiler的
Memory模块,以及第三方工具Memory Profiler(Package Manager中可安装)。 - 常见泄漏点:
- 事件/委托未注销:如果脚本在
OnEnable中订阅了事件,必须在OnDisable中取消订阅,否则该脚本对象无法被垃圾回收,即使GameObject已被销毁。 - 静态引用:静态变量持有对某个对象的引用,会阻止该对象被GC回收。
- 协程引用:启动的协程如果引用了一个对象,在该对象销毁时协程若未停止,也会导致泄漏。使用
MonoBehaviour.StopCoroutine或在OnDestroy中设置一个标志位让协程自行退出。 - 资源未卸载:通过
Resources.Load或AssetBundle.LoadAsset加载的资源,在不用时需要使用Resources.UnloadAsset或AssetBundle.Unload(true)来释放。Addressables系统提供了更清晰的引用计数和释放接口(Release)。
- 事件/委托未注销:如果脚本在
一个典型的事件泄漏示例与修复:
// 有泄漏风险的代码 public class LeakyComponent : MonoBehaviour { void OnEnable() { EventManager.OnDataUpdated += HandleData; // 订阅 } void OnDisable() { // 缺少:EventManager.OnDataUpdated -= HandleData; } void HandleData(Data data) { /* ... */ } } // 当这个GameObject被销毁或禁用时,由于事件仍持有对HandleData方法的引用, // LeakyComponent实例无法被GC回收。 // 修复后的代码 public class SafeComponent : MonoBehaviour { void OnEnable() { EventManager.OnDataUpdated += HandleData; } void OnDisable() { EventManager.OnDataUpdated -= HandleData; // 关键:取消订阅 } void HandleData(Data data) { /* ... */ } }4.3 特定平台问题:WebGL与移动端
- WebGL的网络限制:WebGL构建对于网络请求有更严格的安全限制(CORS)。你的数据API服务器必须正确配置CORS头,允许你的WebGL页面所在域名进行跨域请求。否则
UnityWebRequest会失败。错误信息通常在浏览器控制台查看。 - 移动端的发热与耗电:除了通用的性能优化,在移动端上:
- 尽可能降低帧率(
Application.targetFrameRate = 30)。 - 使用更简单的Shader(移动端优先使用
Built-in Render Pipeline的Mobile分类下的Shader,或URP/HDRP的移动端配置)。 - 减少后处理效果(Bloom, SSAO等)。
- 监控
Profiler中的Battery Usage部分。
- 尽可能降低帧率(
5. 进阶实战:自定义Shader与视觉特效
为了达到特定的视觉效果,如流动的管线、扫描效果、区域高亮,可能需要编写自定义Shader。这里不深入Shader语法,而是给出一个在数字孪生中极其有用的实战案例:基于世界坐标的流动效果,常用于表示管道内流体或能量流动。
目标:在模型表面实现一个沿着特定方向移动的带状高亮效果。
实现思路:在片段着色器中,根据像素的世界坐标计算一个投影值,结合时间变量生成一个周期性的遮罩,用来混合基础颜色和高亮颜色。
一个简化的Unity Shader Graph实现思路(因为代码较长,用Graph节点描述逻辑):
- 获取世界位置:使用
Position节点,空间设置为World。 - 投影到流动方向:假设我们想让效果沿世界空间Z轴流动。使用
Dot Product节点,计算世界位置与一个代表流动方向的向量(如(0,0,1))的点积。这得到了一个标量,表示该点在流动方向上的“进度”。 - 加入时间:使用
Time节点获取游戏时间,乘以一个速度系数(Speed)。 - 生成波纹:将
投影值加上时间*速度,然后输入到Fraction节点取小数部分,得到一个在0-1之间循环的值。再用Remap或条件判断,将这个连续值变成一个脉冲(例如,小于0.2的部分输出1,其余输出0)。这个脉冲就是高亮遮罩。 - 混合颜色:使用
Lerp节点,根据上一步的遮罩(0或1),在Base Color和Highlight Color之间进行插值。 - 控制宽度与位置:可以通过调整点积后的值加上一个偏移量(
Offset)来控制带状高亮出现的位置,通过调整判断阈值来控制带宽。
在C#中动态控制Shader参数:
public class FlowEffectController : MonoBehaviour { public Renderer targetRenderer; public float flowSpeed = 1.0f; public float flowOffset = 0.0f; private MaterialPropertyBlock mpb; void Start() { mpb = new MaterialPropertyBlock(); targetRenderer.GetPropertyBlock(mpb); } void Update() { // 随时间增加偏移量,形成流动动画 flowOffset += Time.deltaTime * flowSpeed; // 将偏移量传递给Shader。假设Shader中有一个名为“_FlowOffset”的Float属性。 mpb.SetFloat(“_FlowOffset”, flowOffset); targetRenderer.SetPropertyBlock(mpb); } }注意事项:
- 性能:复杂的片段着色器计算会对GPU造成压力,尤其是覆盖屏幕面积大的物体。确保只在必要的物体上使用。
- UV vs 世界坐标:上述例子使用世界坐标,效果稳定但模型移动时图案会“粘”在世界空间。如果希望效果随模型移动,可以使用模型本地坐标或UV坐标。UV坐标通常更节省性能,但需要模型有良好的UV布局。
- Shader变体:如果你为不同状态的设备准备了多个颜色或效果的Shader变体,频繁通过
MaterialPropertyBlock切换_Color等属性是高效的。但如果你需要切换整个Shader(例如从“正常”Shader切换到“报警”Shader,后者完全不同的光照模型),那么切换Shader的开销较大,需要权衡。
6. 数据持久化与场景状态管理
对于需要保存视角、设备状态、测量标注等信息的数字孪生应用,数据持久化是必备功能。
6.1 使用SQLite存储结构化数据
SQLite是一个轻量级、文件式的数据库,非常适合在Unity本地存储复杂的配置或历史数据。
基础操作流程:
- 导入SQLite库:从Asset Store安装
SQLite插件(如SQLite3 for Unity),或使用System.Data.SQLite的DLL。推荐使用成熟的插件,它们通常封装好了平台兼容性。 - 创建数据库和表:
using Mono.Data.Sqlite; // 假设使用Mono.Data.Sqlite using System.Data; public class DataService { private string dbPath; public DataService(string databaseName) { #if UNITY_EDITOR dbPath = $“Assets/StreamingAssets/{databaseName}.db”; #elif UNITY_ANDROID dbPath = $“{Application.persistentDataPath}/{databaseName}.db”; #else dbPath = $“{Application.dataPath}/{databaseName}.db”; #endif if (!File.Exists(dbPath)) { CreateDatabase(); } } private void CreateDatabase() { using (var connection = new SqliteConnection($“Data Source={dbPath}”)) { connection.Open(); using (var command = connection.CreateCommand()) { command.CommandText = @“ CREATE TABLE IF NOT EXISTS EquipmentLog ( id INTEGER PRIMARY KEY AUTOINCREMENT, deviceId TEXT NOT NULL, status TEXT NOT NULL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, value REAL )”; command.ExecuteNonQuery(); } } } } - 插入与查询数据:使用标准的SQL语句通过
SqliteCommand执行。
实操心得:
- 线程安全:SQLite操作,尤其是写入,不是线程安全的。确保在同一时间只有一个线程访问数据库连接,或者使用线程锁。一些插件提供了异步或线程安全的封装。
- 路径问题:在Android和iOS上,
Application.streamingAssetsPath是只读的。因此,数据库文件应该创建在Application.persistentDataPath下,这是一个可读写的目录。 - 性能:对于批量插入操作,使用事务(
BEGIN TRANSACTION...COMMIT)可以极大提升速度。
6.2 场景视图状态保存与恢复
用户可能调整了摄像机位置、隐藏了某些图层、添加了注释。关闭应用后,下次打开希望能恢复。
using UnityEngine; using System.IO; using System.Runtime.Serialization.Formatters.Binary; // 注意:BinaryFormatter在部分平台有限制 public class SceneStateManager : MonoBehaviour { public Camera mainCamera; public LayerVisibilityManager layerManager; // 假设管理图层显示的组件 private string savePath; void Awake() { savePath = Path.Combine(Application.persistentDataPath, “sceneState.dat”); } public void SaveSceneState() { SceneState state = new SceneState(); state.cameraPosition = mainCamera.transform.position; state.cameraRotation = mainCamera.transform.rotation; state.cameraFieldOfView = mainCamera.fieldOfView; state.hiddenLayerIds = layerManager.GetHiddenLayerIds(); // 获取当前隐藏的图层ID列表 // 使用JsonUtility进行序列化(更安全,跨平台兼容性好) string json = JsonUtility.ToJson(state); File.WriteAllText(savePath, json); Debug.Log(“场景状态已保存。”); } public void LoadSceneState() { if (File.Exists(savePath)) { string json = File.ReadAllText(savePath); SceneState state = JsonUtility.FromJson<SceneState>(json); mainCamera.transform.position = state.cameraPosition; mainCamera.transform.rotation = state.cameraRotation; mainCamera.fieldOfView = state.cameraFieldOfView; layerManager.RestoreLayerVisibility(state.hiddenLayerIds); Debug.Log(“场景状态已加载。”); } } } [System.Serializable] public class SceneState { public Vector3 cameraPosition; public Quaternion cameraRotation; public float cameraFieldOfView; public List<string> hiddenLayerIds; // 存储图层ID而非直接引用GameObject }重要提示:BinaryFormatter在Unity的新版本和某些平台(如WebGL、iOS)上由于安全原因已被限制或不推荐使用。JsonUtility或第三方库如Newtonsoft.Json(需导入)是更安全、更通用的序列化方案。对于存储Transform信息,JsonUtility完全够用。