拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Unity URP+C# 打造苏绣虚拟展馆:漫游交互与性能优化实战

1. 项目整体设计与技术选型思路做苏绣题材的虚拟展馆最容易踩的第一个坑不是技术而是把它当成一个3D 版的画册来做。真做起来你会发现绣品的视觉信息密度极高——一根丝线在光照下会呈现丝光、渐变、针脚方向三层细节如果只是贴张高清图糊在平面上用户转两圈就腻了。所以这个项目的核心命题其实是用有限的实时渲染预算还原一种需要凑近看的文化质感同时让人愿意在里面多走几步。我采用的方案是 Unity 3DURP 管线 C# 自研漫游与交互模块。这个组合不是随手选的而是因为展馆类项目有三个绕不开的硬需求一是需要在 PC、WebGL、移动端之间切换发布二是要频繁迭代美术和布展热重载和可视化工具有决定性影响三是展品数据不能硬编码得让非程序员也能改。Unity 的 URP 管线在这三点上都给出了足够成熟的答案。下面我按选型→资产→代码→数据→排错的顺序把整个系统拆开讲代码都是可以直接抄进工程里跑的。适合正在做虚拟展厅、数字博物馆、企业线上展馆或者单纯想拿一个完整案例练手 C# 与 Unity 的开发者参考。2. 为什么选 Unity 3D C# 这套组合2.1 渲染管线与引擎版本的确定先把版本这件事说清楚因为它直接决定了后面所有 API 能不能用。我最终锁定的是Unity 2022.3 LTS URP 14.x。选 LTS 而不是最新版的原因很朴素展馆项目周期通常三到六个月中间会经历若干次引擎小版本更新用非 LTS 版本很容易在某个时间点被一次升级打断节奏尤其是 Shader 和光照烘焙相关的部分。URP 相比内置管线在这个项目里带来的最直接收益有两点光照烘焙速度。展馆场景里有大量静态墙面、展柜、地毯这些全部走 Baked 光照。在同等质量设置下URP 的 Progressive GPU Lightmapper 烘焙时间大约是内置管线的三分之一到一半对需要反复调灯的展陈美术来说这个差距是能不能当天改完的区别。后处理的成本可控。苏绣的丝光质感很依赖 Bloom 和色彩分级URP 的 Volume 系统可以按相机分层挂后处理漫游相机开全量、小地图相机直接关掉省下的 GPU 时间非常可观。注意如果你之前用的是内置管线转 URP材质升级后一定要逐个检查自发光和透明材质的 Surface Type。我见过不少案例是转换后透明展柜变成不透明灯光一照整面玻璃变黑板。关于 Mac 上安装的细节如果你用的是较老的 Intel 平台设备建议直接装官方 Unity Hub 来管理版本不要手动下载离线包。Hub 的模块勾选里Windows Build Support 和 WebGL Build Support 即使当前只在 Mac 上调试也建议一并装上因为展馆项目基本都会在最后阶段要求出一个可分享的网页版链接。2.2 展馆的空间叙事与漫游动线设计技术选型定完紧接着要定的是空间本身的逻辑。虚拟展馆和游戏关卡有个本质区别游戏关卡可以用引导牵着玩家走展馆必须允许用户自由乱窜同时又要保证他最后能看到你想让他看的东西。我的处理方式是主线 随机点结构主线是一条固定的参观动线依次经过苏绣历史源流区→针法技艺区→双面绣精品区→现代文创区全程约 120 米虚拟距离正常步行速度走完大约 90 秒。每个区域入口处放一个不显眼的引导光带颜色从暖黄到冷白渐变暗示前进方向。在主线之外我额外布置了若干随机点——比如角落里的绣架工作台、挂在侧墙的单幅作品、可以坐下的休息区。这些点位不参与主线计数但会记录到访数据。区域面积虚拟单位展品数交互类型建议停留历史源流区12×86图文面板 音频60s针法技艺区10×108动画演示 360 观察120s双面绣精品区8×84旋转台 放大镜150s现代文创区14×1010商城式浏览 收藏90s这张表不是拍脑袋写的它是在第一版做完后通过埋点统计用户实际停留时长反推调整的。第一版我把双面绣放在中间结果用户走完历史区就掉了一半人后来把双面绣的旋转台挪到入口右侧当钩子才把人留住。3. 苏绣题材的视觉还原链路3.1 从实物到引擎资产的处理流程这是整个项目最花时间、也最容易被低估的部分。苏绣绣品和普通画作的差别在于表面有物理厚度和丝线方向扫描件直接贴上去会显得平。我用的处理链路是这样的原始采集。单幅绣品用高分辨率翻拍分辨率不低于 6000×4000大幅作品分区拍摄后拼接。这里有个经验拍摄时一定要在同位置放一块 18% 灰卡后期白平衡和色卡校准全靠它。基色与法线分离。基色贴图保留真实色彩法线贴图用高反差灰度图生成用来表现针脚的起伏。乱针绣的法线强度要调到 0.6 左右平绣只需要 0.25否则丝线会像锯齿。细节层叠加。每件绣品额外做一张 Roughness 贴图让丝光沿着针脚方向变化。这一步是像不像苏绣的关键很多项目跳过这步结果无论怎么打光都是一张塑料纸。导入设置。Texture Type 选 DefaultsRGB 勾上基色、关掉法线和粗糙度Max Size 按展品重要程度分档重点展品 4096普通 2048Compression 用 ASTC 6×6移动端或 DXT5PC。3.2 URP 光照与材质参数的实际取值展馆的照明我拆成三层环境层用 Skybox 加低强度环境光Intensity 0.3提供基础可见度。结构层墙面的轨道射灯全部 Baked照亮空间轮廓。展品层每件展品上方挂一个 SpotlightBaked 一个极低强度的 Point Light 混合用来在展品表面制造高光滚动。展品材质我统一用 Lit Shader关键参数如下参数数值说明Smoothness0.550.7乱针绣取高值平绣取低值Metallic0丝线不导光金属度必须为 0Normal Strength0.250.6按针法调整Specular Highlights开关掉就没丝光了Environment ReflectionsMedium反射探头提供环境反光提示展品层如果用实时光照同屏超过 8 个动态光源就会明显掉帧。我的做法是优先 Baked只有需要用户旋转观察的展品才用实时 Spot 加 Reflection Probe 组合。4. 交互漫游系统的核心代码实现4.1 双模式漫游控制器的实现漫游模式我做了两套第一人称自由行走和定点轨道观察。用户在任何位置按 F 键可以切换到定点模式此时相机会平滑过渡到最近的观察点用户可以环绕展品旋转再按 F 返回自由模式。自由行走用 Unity 自带的 CharacterController 而不是 Rigidbody原因是展馆地面平整、没有物理堆叠需求CharacterController 的确定性移动手感更好控制不会出现被楼梯弹飞的情况。using UnityEngine; [RequireComponent(typeof(CharacterController))] public class FreeWalkController : MonoBehaviour { [Header(移动参数)] public float walkSpeed 2.6f; // 接近真实步速走太快会晕 public float runSpeed 4.5f; public float gravity -12f; public float smoothTime 0.08f; // 速度阻尼避免起步顿挫 [Header(视角参数)] public Transform cameraPivot; public float mouseSensitivity 2.2f; public float minPitch -75f; public float maxPitch 75f; private CharacterController _cc; private Vector3 _velocity; private Vector3 _currentMove; private Vector3 _moveDamp; private float _pitch; void Awake() { _cc GetComponentCharacterController(); Cursor.lockState CursorLockMode.Locked; Cursor.visible false; } void Update() { HandleLook(); HandleMove(); } // 水平方向带阻尼垂直方向直接积分避免浮空 void HandleMove() { float h Input.GetAxisRaw(Horizontal); float v Input.GetAxisRaw(Vertical); Vector3 input new Vector3(h, 0f, v).normalized; float speed Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed; Vector3 target transform.TransformDirection(input) * speed; _currentMove Vector3.SmoothDamp(_currentMove, target, ref _moveDamp, smoothTime); if (_cc.isGrounded _velocity.y 0f) _velocity.y -2f; // 贴地一点小下压力防止下坡时腾空 _velocity.y gravity * Time.deltaTime; _cc.Move((_currentMove _velocity) * Time.deltaTime); } void HandleLook() { float mx Input.GetAxis(Mouse X) * mouseSensitivity; float my Input.GetAxis(Mouse Y) * mouseSensitivity; transform.Rotate(Vector3.up * mx); _pitch Mathf.Clamp(_pitch - my, minPitch, maxPitch); cameraPivot.localEulerAngles new Vector3(_pitch, 0f, 0f); } }这段代码里有两个参数值得单独说。第一个是walkSpeed 2.6f这是多次实测调出来的2.0 太慢用户会觉得展馆很大走不到头3.5 以上开始有轻微的晕动感尤其是第一次用的人。第二个是smoothTime 0.08f阻尼太小会显得滑太大像在冰面上0.08 是扎实但不生硬的甜点值。4.2 展品交互拾取与信息面板交互拾取最容易出问题的地方是射线打到了展品的子物体比如展柜玻璃、装饰托架导致面板弹不出来或者信息错乱。我的做法是给可交互展品统一挂一层交互脚本射线打中的物体向上查找这个组件。public class ExhibitInteractable : MonoBehaviour { [SerializeField] private string exhibitId; [SerializeField] private Transform focusPoint; // 定点观察时的相机落点 public string ExhibitId exhibitId; public Transform FocusPoint focusPoint; } public class InteractionScanner : MonoBehaviour { public float maxDistance 4.5f; // 超过这个距离不响应避免远处误触 public LayerMask interactMask; public float reticleRadius 0.15f; // 用球形投射替代细射线手感更稳 private ExhibitInteractable _current; void Update() { ExhibitInteractable hit Probe(); if (hit ! _current) { if (_current ! null) _current.SendMessage(OnFocusExit, SendMessageOptions.DontRequireReceiver); _current hit; if (_current ! null) _current.SendMessage(OnFocusEnter, SendMessageOptions.DontRequireReceiver); } if (_current ! null Input.GetMouseButtonDown(0)) ExhibitPanel.Instance.Open(_current.ExhibitId); } ExhibitInteractable Probe() { Ray ray new Ray(transform.position, transform.forward); if (Physics.SphereCast(ray, reticleRadius, out RaycastHit info, maxDistance, interactMask, QueryTriggerInteraction.Ignore)) { // 向上查找处理子物体命中 return info.collider.GetComponentInParentExhibitInteractable(); } return null; } }这里用 SphereCast 而不是普通 Raycast 是个很实用的技巧。准星只有一像素宽用户瞄准一面绣品的边缘时如果恰好打在背景墙上体验会非常挫败。给一个 0.15 的球形半径相当于给准星加了一圈宽容度实测下来误触率没有明显上升但命中率提升非常明显。注意QueryTriggerInteraction.Ignore一定要加。展馆里到处都是用来做碰撞边界的触发器比如语音讲解触发区不加这个参数会出现看向空气弹出展品信息的诡异现象。4.3 楼层小地图与导览系统小地图我踩过最大的坑是一开始用 UI 图标实时换算世界坐标来做结果展馆不是正方形图标和实际建筑对不上。后来改成正交相机渲染到 RenderTexture的方案一次配置、永久正确。具体做法是新建一个 CameraProjection 设为 OrthographicSize 覆盖整个楼层平面位置放在展馆正上方朝下看Culling Mask 只勾选底面轮廓层。把它的 Target Texture 设成一张 512×512 的 RenderTextureUI 里用 RawImage 显示这张图。玩家图标用另一张小图叠在 RawImage 上位置通过世界坐标归一化计算public class MinimapMarker : MonoBehaviour { public Transform player; public RectTransform marker; public RectTransform mapRect; public Vector2 worldMin; // 楼层左下角世界坐标 public Vector2 worldMax; // 楼层右上角世界坐标 void LateUpdate() { Vector3 p player.position; float u Mathf.InverseLerp(worldMin.x, worldMax.x, p.x); float v Mathf.InverseLerp(worldMin.y, worldMax.y, p.z); Vector2 size mapRect.rect.size; marker.anchoredPosition new Vector2(u * size.x, v * size.y); marker.localEulerAngles new Vector3(0f, 0f, -player.eulerAngles.y); } }多楼层的话我的方案是每层一张 RenderTexture切换楼层时切图同时在楼层标题旁放一个你在 2F的提示。这里有个细节容易被忽略LateUpdate而不是Update。玩家移动在Update里算完小地图跟随时如果同在Update会因为执行顺序不确定出现图标轻微抖动用LateUpdate就干净了。5. 展品数据体系与内容配置5.1 用 ScriptableObject 搭建展品数据层展品信息绝对不能写进 MonoBehaviour不然美工改个描述都要找程序开工程。我用 ScriptableObject 做数据载体每个展品一个资产文件。[CreateAssetMenu(menuName 展馆/展品数据, fileName Exhibit_)] public class ExhibitData : ScriptableObject { public string id; public string titleCN; public string titleEN; [TextArea(4, 10)] public string description; public string[] craftTags; // 针法标签 public Sprite mainImage; public AudioClip narration; // 语音讲解 public float narrativeLength; // 讲解时长用于自动推进 public ExhibitCategory category; } public enum ExhibitCategory { History 0, Technique 1, DoubleSided 2, Modern 3 }好处很直接美术在 Project 窗口右键就能新建一件展品填完字段拖到展位脚本上就完成了全程不碰代码。而且 ScriptableObject 是项目管理在版本控制里的资产改动能被追溯。5.2 CSV 批量导入与内容更新展品数量一多一个个手填就变折磨了。我写了一个编辑器脚本从 CSV 批量生成 ExhibitData。#if UNITY_EDITOR using UnityEditor; using UnityEngine; using System.IO; public static class ExhibitCsvImporter { [MenuItem(展馆/从 CSV 导入展品)] public static void Import() { string path EditorUtility.OpenFilePanel(选择展品 CSV, , csv); if (string.IsNullOrEmpty(path)) return; string[] lines File.ReadAllLines(path); int created 0; // 第一行是表头从第二行开始 for (int i 1; i lines.Length; i) { string[] f SplitCsvLine(lines[i]); if (f.Length 5) continue; ExhibitData data ScriptableObject.CreateInstanceExhibitData(); data.id f[0]; data.titleCN f[1]; data.titleEN f[2]; data.description f[3]; data.craftTags f[4].Split(|); string assetPath $Assets/Exhibits/{data.id}.asset; AssetDatabase.CreateAsset(data, assetPath); created; } AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); Debug.Log($导入完成共生成 {created} 个展品资产); } // 处理字段里带逗号的情况 static string[] SplitCsvLine(string line) { var result new System.Collections.Generic.Liststring(); bool inQuote false; var sb new System.Text.StringBuilder(); foreach (char c in line) { if (c ) inQuote !inQuote; else if (c , !inQuote) { result.Add(sb.ToString()); sb.Clear(); } else sb.Append(c); } result.Add(sb.ToString()); return result.ToArray(); } } #endifSplitCsvLine这个手写解析是为了处理描述里带逗号的情况。很多项目直接Split(,)结果第一条带逗号的描述就把字段错位了后面所有展品信息全乱。这个坑我在别的项目里踩过所以这里直接绕开。5.3 事件总线解耦各模块入口处走到某个位置触发语音讲解这件事涉及区域触发器、音频系统、UI 字幕三个模块。如果直接互相引用后期改一个就动全身。我用一个极简的事件总线把它们分开using System; using System.Collections.Generic; public static class EventBus { static readonly DictionaryType, Delegate _handlers new(); public static void SubscribeT(ActionT handler) where T : struct { _handlers.TryGetValue(typeof(T), out var d); _handlers[typeof(T)] (ActionT)d handler; } public static void UnsubscribeT(ActionT handler) where T : struct { if (_handlers.TryGetValue(typeof(T), out var d)) { var next (ActionT)d - handler; if (next null) _handlers.Remove(typeof(T)); else _handlers[typeof(T)] next; } } public static void PublishT(T evt) where T : struct { if (_handlers.TryGetValue(typeof(T), out var d)) (d as ActionT)?.Invoke(evt); } } public struct ExhibitFocusedEvent { public string exhibitId; } public struct AreaEnteredEvent { public string areaId; }用struct约束事件类型是为了避免每次发布都产生堆分配。在展馆里用户可能一秒内反复进出触发区如果用 class 做事件参数GC 会持续产生压力低配设备上表现为规律性的卡顿。提示订阅了一定要在OnDestroy里退订。静态字典的生命周期比场景长忘记退订会导致场景重载后旧对象仍被引用出现同一个展品弹两次面板的问题。6. 常见问题与排查技巧实录6.1 漫游手感与碰撞相关的坑问题一走上楼梯时角色被卡住或者弹飞。标准 CharacterController 的 Slope Limit 默认 45 度如果你的台阶是高 0.2 米、深 0.25 米实际坡度就已经超过 45 度了。解决方案有两条一是把 Step Offset 调到 0.3 以上让角色直接踩上去二是把台阶改成斜坡碰撞体。我选的是前者因为它不会改变视觉上的陡峭感。但 Step Offset 不能超过 0.35否则角色会能爬上本该挡住他的矮墙。问题二快速转身穿过薄墙。CharacterController 的Move是按帧逐步位移如果单帧位移量大于墙的厚度就会直接穿过去。我的处理是把walkSpeed上限压到 5 以内同时在斜向穿行的墙体内侧加一层 0.1 米厚的备份碰撞层。问题三鼠标视角在暂停菜单里仍在转。Input.GetAxis不区分 UI 焦点。我在菜单打开时把Cursor.lockState切回 None并在控制器里加一个updateEnabled开关由菜单通过事件控制比enabled false更可控因为后者会连带着停止协程。6.2 打包与平台适配问题WebGL 端的字体问题。中文字体如果用了系统字体WebGL 打包后会全部变成方块。必须用 TextMeshPro 的 Font Asset 方式烘焙中文字集并且只烘焙用到的字符否则一个完整中文字库会让包体直接涨 10MB 以上。我一般是从展品 CSV 里提取所有出现过的字生成一个精简字符集。WebGL 的内存上限。浏览器端堆内存默认上限我遇到过展品贴图全部 4096 时加载到一半直接崩的情况。解决办法是把非重点展品的贴图 Max Size 降到 1024重点展品保留 2048并且用 Addressables 做按需加载进入某个区域才加载该区域的展品资源。移动端的触摸与鼠标输入混用。Input.GetMouseButtonDown在移动端会被映射成触摸但多指操作会出问题。我的做法是写一层 InputRouter运行平台判断后用不同分支触摸端用Input.touchCount手动处理摇杆用 EasyTouch 类的虚拟摇杆组件。6.3 排查速查表下面这张表是我自己攒的基本覆盖了这个项目 80% 的线上问题现象可能原因排查动作展品点击无响应射线被触发器挡住检查 QueryTriggerInteraction面板弹出两遍事件未退订检查 OnDestroy 中的 Unsubscribe小地图图标偏移世界坐标范围设置错打印 worldMin/worldMax 对比实际走起来头晕walkSpeed 过高或阻尼缺失降到 2.6 并加 SmoothDamp丝光不明显Metallic 非 0 或法线强度低金属度设 0法线 0.40.6WebGL 加载白屏贴图总量超限降 Max Size改 Addressables烘焙后阴影漏光模型未设 Contribute GI检查 Static 标记场景切换后音频不停AudioSource 挂在常驻对象手动 Stop 或换 AudioMixer 分组6.4 关于 C# 延时逻辑的效率选择展馆里的延时逻辑很多比如焦点进入 0.5 秒后弹出提示。这里有三条路可以走我实测下来各自的适用场景区别很大Invoke(Method, 0.5f)写起来最快但用的是反射查找方法名高频调用时开销明显而且不能在编辑器里被静态检查发现拼写错误。适合一次性、低频的简单延时。协程yield return new WaitForSeconds(0.5f)最常用但每次new一个 WaitForSeconds 会产生少量 GC。如果这个延时每秒触发几十次就要缓存这个实例复用。async/awaitTask.Delay写法最现代但 Unity 里要自己处理线程回调跨线程访问 Transform 会直接报错。非必要不用。我的选择是高频延时用缓存协程低频延时用 Invoke。这个判断标准是每分钟触发次数——超过 30 次就上缓存。最后分享一个我用了很久的小技巧也是被用户问得最多的一个点展馆里一定要做一个一键回到入口的按钮。测试期间我发现用户走丢了之后最常见的反应是直接关掉页面而不是去找地图。加了这个按钮之后平均会话时长涨了将近四成。这个功能实现起来只有几行代码但它对完整参观率的影响比多放十件展品要大得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门