1. 项目整体设计与技术选型思路
汉绣这类非遗题材做数字化展示,最早我接触到的方案大多是网页端的360度全景图,点一下换个角度,看两眼就腻了。真正想做“展馆”那种沉浸感——推开大门、顺着回廊走、凑近看展柜里的绣品针脚——网页方案要么加载卡顿,要么光照假得离谱。所以这套基于Unity + 3D + C#的汉绣文化主题虚拟展馆交互漫游系统,本质上解决的就是一个问题:用实时渲染引擎把线下展馆的空间感、材质感和探索感搬到屏幕上。它面向的读者很广,Unity 初学者可以当成一个完整的练手项目,非遗数字化方向的从业者可以参考里面内容转化的思路,做数字展厅、校史馆、博物馆的同行也能直接套用漫游和交互框架。
先说说为什么最终拍板 Unity 而不是别的引擎。跨平台是第一位的,同一套工程,导出 PC 客户端给展厅大屏用,导出 WebGL 挂到服务器让人在线看,甚至改一改部署到 VR 一体机上,代码几乎不用动。其次是C# 这一层的开发效率,托管语言不用手动管内存,改一行编译几秒钟就能在编辑器里跑起来,调漫游手感这种需要反复试参数的事情,迭代越快越省命。第三是 Unity 的资源生态和光照烘焙能力,展馆场景大量依赖静态光照和 Lightmap,这块 Unity 的 Enlighten 加 Progressive GPU 烘焙器成熟度很高,出来的效果比自己在 WebGL 里手搓着色器靠谱太多。
1.1 需求边界怎么划,避免做成“四不像”
我见过不少同类项目翻车,都是因为一开始没想清楚要做什么。有人想做成游戏,加了任务系统、成就、背包;有人想做成建筑漫游,结果展品交互又很敷衍。这个项目的定位我建议锁死在三个关键词上:漫游、观展、导览。漫游是第一人称自由走,观展是靠近展品触发信息面板,导览是给不想乱逛的人一条预设路线。除此之外的东西,比如多人联机、聊天、点赞,在文化展馆场景里属于典型的“投入大、收益低”,直接砍掉。
把功能拆开看,大致是下面这张表里的范围:
| 功能模块 | 具体内容 | 优先级 |
|---|---|---|
| 第一人称漫游 | WASD 移动、鼠标转视角、跳跃可选、碰撞阻挡 | 必须 |
| 展品交互 | 准星射线检测、高亮提示、点击弹出图文/音视频 | 必须 |
| 导览路线 | 路径点动画、自动移动、可随时退出 | 重要 |
| 环境氛围 | 背景音、脚步音、展品解说、灯光呼吸 | 重要 |
| 小地图 | 平面缩略图 + 玩家位置指示 | 可选 |
| 存档 | 记录已看展品、导览进度 | 可选 |
提示:功能边界一定要在原型阶段就定死。我吃过亏,做到一半临时加“双击展品放大看细节”的需求,结果发现所有展品的模型精度根本不够放大,最后只能全部返工重做高模,工期直接翻倍。
1.2 工程目录与资源组织规范
Unity 项目最怕的就是 Assets 目录乱成一锅粥,几百个资源全堆在根目录,找一张贴图要翻半天。这个项目的资源量不算大,但模型、贴图、音频、预制体、脚本、动画一样不少,所以从第一天就得分好目录。我的习惯是按类型加场景双层划分:
Assets/_Project/Scripts:所有 C# 脚本,再按Player、Interaction、UI、Manager分子目录。Assets/_Project/Art/Models:按展品编号建子文件夹,一个展品一个文件夹,里面放它的 fbx、材质、贴图。Assets/_Project/Art/Textures:公共贴图,比如地面、墙面、木纹。Assets/_Project/Art/Lighting:Lightmap 和光照数据,这个必须单独放,烘焙数据文件很大。Assets/_Project/Prefabs:预制体,展品、灯具、展柜都做成预制体。Assets/_Project/Audio:BGM、音效、解说,命名带上bgm_、sfx_、vo_前缀。Assets/_Project/Scenes:主场景、加载场景、测试场景。
命名上我强制自己用英文加下划线,比如Exhibit_HanXiu_01.fbx,因为 Unity 对中文路径的支持在某些打包流程里会出玄学问题,尤其是导出 WebGL 的时候。中文名只出现在最终显示给用户的 UI 文本里,资源层一律英文。
2. 汉绣文化内容的三维化处理与建模要点
非遗题材和普通游戏场景最大的区别在于:展品是内容的核心,模型精度和贴图还原度直接决定项目成败。汉绣的绣品本身是平的,但它的价值在于丝线的走向、颜色的层次、金线的反光,这些细节如果模型和贴图做糊了,整个展馆就变成了一个空壳子。所以这一块我花了整个项目大概四成的时间。
2.1 从实物到模型的采集与减面流程
汉绣展品的采集,我当时用了两条路。一条是去实地拍摄高分辨率照片,用摄影测量的方式重建,适合有立体结构的物件,比如绣屏、绣架、立轴。另一条是用3D 结构光相机对小型绣品做近距离扫描,它点云密度高,能还原出刺绣表面的起伏。不过结构光扫描有个坑,遇到深色或者反光的丝线表面,容易丢点,所以扫描前我会在绣品上喷一层很薄的哑光显影剂,扫描完再清掉。
拿到高模之后,下一步是减面。一个从结构光扫出来的绣品高模动辄几十万面,直接丢进 Unity 帧率直接崩。展品我会控制在5000 到 20000 三角面之间,具体看它在场景里离观众多远。放在展柜里让人凑近看的,给 15000 面以上;挂在远处墙上的装饰绣片,3000 面就够了。减面用 Blender 的 Decimate 或者 ZBrush 的 Decimation Master,减完之后一定要重新展 UV,因为自动减面会把原来的 UV 打得乱七八糟。
这里有个容易被忽略的点:减面之后法线的处理。汉绣的绣面有丝线的蓬松感,如果只靠几何体,减面后细节全丢,看着像塑料片。我的做法是把高模的法线细节烘焙到低模的切线空间法线贴图上,这一步是在 Substance Painter 或者 XNormal 里做的。烘焙的时候要注意高模低模的包裹关系,包裹距离设太大会把周围的杂线也烘进去,太小又有细节烘不到,我一般先用自动包裹,再手动调Max Rear Distance到模型尺寸的 5% 左右。
2.2 汉绣纹样的贴图还原与材质调校
汉绣的色彩特点是浓烈、饱满,常用大红、深蓝、墨绿打底,再用金线勾边,行话叫“平金夹绣”。要让它在实时渲染里出效果,材质不能简单地给个颜色了事。我在 Unity 里用的是 URP 管线的 Lit 材质,重点调三个参数:
- Base Map:绣品的底色和图案,分辨率至少 2048,凑近看的展品给到 4096。
- Metallic / Smoothness:金线的部分单独做一张遮罩贴图,金属度拉到 0.8 以上,光滑度 0.6 左右,这样才能有那种金属丝线的反光。
- Normal Map:从高模烘出来的法线,强度不要拉满,0.6 到 0.8 之间比较自然,拉满会显得像塑料。
丝线还有一个特性是各向异性反光,也就是顺着丝线方向的反光和垂直方向不一样。Unity 的 Lit 材质不带这个,如果你的展品是特写重点,可以换成各向异性着色器,或者用一张细节法线贴图去模拟丝线的走向。我自己实测下来,对于中远景的展品,法线模拟完全够用,只有那种“贴脸看针脚”的展品才需要上各向异性,否则性价比不高。
贴图管理上要养成合图的习惯。一个展馆如果几十个展品每个都挂三四张贴图,Draw Call 会爆炸。我的做法是把同一区域的展品贴图打进一张2D Array 或者 Atlas,材质合并成一个,渲染时就能批处理。这里要注意 Atlas 的图集不能太大,超过 4096 在部分移动端 GPU 上会出问题。
2.3 展馆建筑空间与光照烘焙
展馆本身的空间设计,我参考了传统中式建筑的“院落—回廊—展厅”结构,进门是一个小前厅,左右两侧是主题展厅,中间一个天井采光。这么做不只是为了好看,更因为回廊和隔断能把大场景切成小块,天然利于遮挡剔除,玩家站在前厅看不到后面展厅的模型,引擎就能把它剔除掉,性能直接上来了。
建筑模型尽量用模块化搭建,墙、柱、梁、地砖都做成标准件,重复拼装。这样好处有两个:一是建模快,二是光照烘焙时可以复用光照数据,相同的模块只需要烘一次。光照这块我用的是混合光照模式,主光源是一盏方向光模拟天光,展厅内部靠点光源和射灯。展柜里的射灯是关键,它要能照亮展品但不能照亮周围环境,所以我把射灯的 Culling Mask 设置成只影响展品所在的 Layer。
注意:Lightmap 的分辨率不要一刀切全给高。墙面、地面这种大面积的东西给 20 到 40 texel 每单位就够,展品和展柜周围给 60 以上。全场景高分辨率烘焙,一张 Lightmap 能到几百兆,加载慢还占内存。
烘焙前还有一步不能省,就是设置好每个物体的 Static 标记。只有勾了Contribute GI的静态物体才会参与烘焙,动态物体比如玩家、可开合的柜门不能勾,否则它们会把光照烘进场景里,移动的时候就会留下一块黑影子。这个坑我踩过,当时整个展厅地上有一块人形的阴影擦不掉,排查了半天才发现是玩家角色被误标记成静态了。
3. 交互漫游系统的核心实现
内容做完了,接下来是让它“能玩”。这一块是整个项目的骨架,C# 脚本主要都集中在这里。我的原则是逻辑和表现分离,控制器只管移动,交互只管检测和触发,UI 只管显示,中间用事件解耦。这样做的好处是改一处不影响另一处,比如把第一人称换成第三人称,只需要换控制器,交互和 UI 完全不用动。
3.1 第一人称角色控制器与漫游手感调参
Unity 自带的 CharacterController 组件是做第一人称漫游最省事的方案,它自带胶囊碰撞,不需要 Rigidbody,斜坡和台阶处理也还行。核心脚本我贴一下简化版:
using UnityEngine; [RequireComponent(typeof(CharacterController))] public class PlayerWalker : MonoBehaviour { public float walkSpeed = 3.0f; public float runSpeed = 5.5f; public float jumpHeight = 1.1f; public float gravity = -18f; public Transform cameraRoot; public float mouseSensitivity = 2.2f; public float maxPitch = 80f; private CharacterController controller; private Vector3 velocity; private float pitch = 0f; void Start() { controller = GetComponent<CharacterController>(); Cursor.lockState = CursorLockMode.Locked; } void Update() { // 视角 float mx = Input.GetAxis("Mouse X") * mouseSensitivity; float my = Input.GetAxis("Mouse Y") * mouseSensitivity; transform.Rotate(Vector3.up * mx); pitch = Mathf.Clamp(pitch - my, -maxPitch, maxPitch); cameraRoot.localEulerAngles = new Vector3(pitch, 0f, 0f); // 移动 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 move = (transform.right * h + transform.forward * v).normalized; float speed = Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed; if (controller.isGrounded && velocity.y < 0) velocity.y = -2f; velocity.y += gravity * Time.deltaTime; controller.Move(move * speed * Time.deltaTime + velocity * Time.deltaTime); } }速度参数我反复调过很多次。走路 3.0 米每秒、跑步 5.5 米每秒是展厅场景比较舒服的区间,太快了像在跑酷,太慢了逛一个展厅要五分钟,用户直接没耐心。鼠标灵敏度 2.2 是主流 FPS 手感的中间值,如果你想让展馆这种慢节奏场景更稳一点,可以降到 1.8。视角俯仰限制在正负 80 度,是为了防止玩家把头转到脖子后面去,看着很怪。
还有一个细节是相机高度和晃动。相机节点挂在角色眼睛高度,大概 1.65 米。如果完全不晃,走起来像幽灵飘,加点轻微的上下起伏会真实很多。但幅度不要大,我试过 0.05 米的起伏,看久了容易晕,最后定在 0.03 米左右,配合步伐节奏。晕动症这一块对展馆类项目很重要,尤其是要放到大屏或者一体机上给普通观众用,做得太刺激会被投诉。
3.2 展品交互与信息面板的触发逻辑
交互的核心是射线检测。从屏幕中心或者相机位置打一条射线出去,碰到展品就高亮并显示提示,点击就弹面板。脚本大概长这样:
using UnityEngine; using UnityEngine.UI; public class ExhibitInteractor : MonoBehaviour { public Camera playerCam; public float interactDistance = 3.0f; public LayerMask exhibitLayer; public Text hintText; public ExhibitPanel panel; private Exhibit current; void Update() { Ray ray = new Ray(playerCam.transform.position, playerCam.transform.forward); if (Physics.Raycast(ray, out RaycastHit hit, interactDistance, exhibitLayer)) { Exhibit ex = hit.collider.GetComponent<Exhibit>(); if (ex != current) { SetCurrent(ex); } hintText.text = "按 E 查看 " + ex.displayName; if (Input.GetKeyDown(KeyCode.E)) panel.Show(ex.data); } else if (current != null) { SetCurrent(null); hintText.text = ""; } } void SetCurrent(Exhibit ex) { if (current != null) current.SetHighlight(false); current = ex; if (current != null) current.SetHighlight(true); } }这里有几个实用细节。交互距离设 3 米,是因为展柜玻璃通常离观众一两米,展品在柜子里,3 米能覆盖大多数凑近看的场景,太远了会误触发,太近了要贴脸才能点。LayerMask 一定要单独给展品建一层,否则射线会打到墙、地、装饰物上,出现隔着墙也能点展品的 bug。高亮效果我用的是描边(Outline)加一层淡淡的发光,比单纯换颜色明显得多。
展品的数据我建议用ScriptableObject存,一个展品一个资产,里面放名称、年代、工艺介绍、图片、音频。这么做的好处是内容编辑和程序完全分离,改文案不用碰代码,策划或者做内容的人自己就能改。面板弹出的时候要暂停漫游输入,否则玩家还在走,面板跟着晃,体验很差。我一般会把Time.timeScale设成 0 或者直接关掉控制器的输入开关。
3.3 导览路线、音效与整体氛围
导览功能是给“懒人”准备的。实现上很简单,就是一条路径点数组,相机或者角色沿着点做插值移动,到点停顿几秒播放解说,然后继续。我用的是Vector3.Lerp加一个MoveTowards,配合朝向的Quaternion.Slerp。要注意转弯处的处理,如果直接线性插值,转角会生硬,加一个贝塞尔曲线平滑一下会好很多。导览过程中玩家随时按键就能退出,切回自由漫游,这个交互必须留,否则用户被困在导览里会很烦躁。
音效是氛围感的半条命。我分了四层:背景音乐用一段悠远的古琴曲,音量压低;环境音是展厅的空旷混响,循环播放;脚步音跟着移动触发;解说音在交互或导览时播放。音频的混音用 Unity 的 AudioMixer,分组管理,用户可以单独调 BGM 和解说的音量。这里有个小技巧,展厅空间大,直接播放声音会很干,给 AudioSource 加一个 Reverb Zone 或者用混响滤镜,声音就有了空间的包裹感,一下子高级起来。
提示:解说音频一定要做渐入渐出,别咔嚓一声开始又咔嚓一声结束。我习惯在 AudioSource 上做 0.3 秒的淡入淡出,听感差别巨大。另外 WAV 格式虽然无损,但体积大,解说这种长音频建议转成压缩格式,短音效保留 WAV。
4. 性能优化与打包发布
展馆类项目的性能压力主要来自三块:大量静态模型、高分辨率贴图、实时光照。前面内容做得越精细,这一块越要花心思。优化不是最后才做的事,我在建模阶段就已经在考虑面数预算和贴图尺寸了,等到打包前再优化,返工成本高得吓人。
4.1 渲染层面的优化手段
批处理是第一步。相同材质的静态物体勾上Static就能自动合批,动态物体用 GPU Instancing。展馆里重复的元素特别多,比如展柜、地砖、装饰灯,这些做成预制体复用,配合 Instancing 能把 Draw Call 压到很低。我那个项目最终 Draw Call 稳定在 200 以内,靠的就是这个。
LOD对大型展品和建筑构件很有用。远处的绣屏用低模,走近了自动切高模,肉眼几乎看不出切换。LOD 的切换距离要配合相机 FOV 和场景尺度调,切得太近会看到明显的模型跳变,切得太远又没起到优化作用。遮挡剔除(Occlusion Culling)是回廊式展馆的杀手锏,烘焙一次遮挡数据,玩家在哪个房间就只渲染那个房间的东西,帧率能翻倍。烘焙遮挡的时候要注意把 Agent 半径和高度设对,设太大会把半开的门都当成遮挡物,导致该看见的看不见。
贴图压缩也不能忽视。PC 端可以用 DXT,移动端和一体机优先ASTC,同画质下体积小很多。还有 Mipmap 一定要开,远处的贴图自动降分辨率,能省不少带宽和显存。
4.2 资源加载与内存管理
场景大了之后,一次性加载所有资源会卡顿甚至崩溃。我用的是Addressables做异步加载,把展馆分成几个区块,玩家走到哪个区块再加载哪个区块的资源,离开就卸载。加载的时候给个转圈提示,别让画面直接黑掉,用户会觉得程序死了。
内存这块最容易被忽略的是RenderTexture 和临时对象。UI 里如果用了相机渲染到纹理做小地图,记得在不用的时候Release掉。还有一些脚本里new出来的 Vector3 数组,高频调用会触发 GC,导致周期性卡顿,能缓存的就缓存成成员变量。
| 优化项 | 手段 | 预期收益 |
|---|---|---|
| Draw Call | 静态批处理 + GPU Instancing | 降 50% 以上 |
| 三角面 | LOD + 遮挡剔除 | 降 40% 以上 |
| 贴图内存 | ASTC 压缩 + Mipmap | 降 60% 左右 |
| 加载卡顿 | Addressables 分块异步加载 | 消除主线程阻塞 |
| GC 卡顿 | 对象池 + 缓存变量 | 消除周期性掉帧 |
4.3 打包与部署的注意事项
PC 客户端打包最省心,直接 Build 就行,注意把窗口分辨率模式设成可调,展厅大屏和笔记本都要能用。WebGL 打包坑最多,首先首包体积要控制,超过 30 兆加载就很慢了,得靠 Addressables 把资源放服务器上按需拉取。其次是浏览器对内存有限制,场景不能做太大。部署到 IIS 上的时候,要配置 MIME 类型,.wasm、.data、.js.gz这些后缀不配好,浏览器会拒绝加载,页面直接白屏。压缩传输开 Brotli 或者 Gzip,加载速度能快好几倍。
如果要做 VR 一体机版本,比如基于 PICO 这类设备,渲染压力比 PC 大得多,单眼分辨率高、刷新率要求高,所有优化都要更激进。这种情况下展品面数要往下降,实时光源能砍就砍,全靠烘焙。手柄交互也要重做一套,把射线检测换成手柄射线。
5. 常见问题与排查技巧实录
做得多了就会发现,展馆项目的问题翻来覆去就那么几类,我把自己踩过的坑整理成一张速查表,遇到问题先对号入座。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 漫游时画面抖动 | 移动逻辑写在 Update 里没乘 deltaTime | 所有位移乘Time.deltaTime |
| 角色穿墙或卡进模型 | 碰撞体是 Mesh Collider 且没勾 Convex | 换成胶囊/盒碰撞体,或勾 Convex |
| 隔着墙能点到展品 | 射线没限制 LayerMask | 建展品专用 Layer,设置 LayerMask |
| 展品高亮不消失 | 高亮状态没在离开时复位 | 切换到 null 时关闭描边 |
| 地面有一块擦不掉的黑影 | 动态物体被标记成 Static 参与烘焙 | 取消勾选 Contribute GI |
| 烘焙后展柜玻璃发黑 | 透明材质参与 GI 计算异常 | 玻璃材质关闭 GI,改用反射探针 |
| WebGL 加载白屏 | 服务器 MIME 类型未配置 | 配置 wasm/data 的 MIME |
| 帧率周期性掉一下 | GC 频繁回收临时对象 | 对象池、缓存变量、避免频繁 new |
| 光照切换时闪烁 | 混合光照模式参数不当 | 调 Shadowmask 或改用 Baked Indirect |
5.1 漫游手感与碰撞的老大难问题
抖动和穿模是最常见的两个投诉。抖动十有八九是没乘 deltaTime,帧率一波动,位移就跟帧率挂钩了,看着就是一顿一顿的。穿模则多和碰撞体有关,Unity 的 CharacterController 对 Mesh Collider 的支持不好,遇到复杂的建筑模型,最好用简化的 Box 或者 Capsule 碰撞体包裹,别指望直接拿建筑网格当碰撞。台阶处理也是坑,高度超过 0.3 米的台子,CharacterController 的Step Offset要相应调大,否则上不去。斜坡的话Slope Limit默认 45 度,中式展馆的坡道一般不超过 20 度,够用。
5.2 交互逻辑的隐性 bug
交互失效往往不是射线的问题,而是生命周期顺序的问题。比如展品预制体在Start里注册到管理器,但管理器在Awake里就已经初始化完了,时序对不上就漏注册。这类问题我一般用事件中心统一分发,或者用OnEnable/OnDisable成对注册注销,比在 Start 里硬塞稳妥。另一个常见问题是 UI 面板和 3D 交互抢夺点击,鼠标点在 UI 上时射线还在往场景里打,导致点面板的同时又触发了展品。解决办法是用EventSystem.current.IsPointerOverGameObject()判断一下,鼠标在 UI 上就跳过场景交互。
5.3 光照烘焙的疑难杂症
烘焙这块的坑最玄学。除了前面说的动态物体误标静态,还有一个常见的是Lightmap 接缝处有黑线,这是因为相邻模型的 UV 没对齐或者光照图分辨率不够,解决办法是把相邻模块的 UV 合并烘焙,或者提高那一片的 Lightmap 密度。玻璃材质发黑是因为透明物体参与 GI 计算时,光线穿过它会产生错误的遮挡,给玻璃关掉Cast Shadows或者不参与 GI 就能解决。还有光照数据文件太大的问题,可以把光照图压缩格式改成 LDR,精度够用体积小一半。
提示:每次改完光照参数都要重新烘焙,别偷懒用实时光凑合。实时光在展厅这种多光源环境下噪点很多,必须配合降噪,性能和效果都不如烘焙。烘焙时间可以选在晚上跑,白天继续调逻辑。
最后分享一个我个人的习惯,就是给自己留一个“问题记录本”,每解决一个 bug 就记一笔现象、原因和解决办法。做到后面你会发现,同一个坑换个场景还会再来一次,有本子直接翻,能省大把时间。汉绣虚拟展馆这类项目,技术上没有特别深的黑科技,拼的就是细节和耐心,把内容做扎实、把手感调舒服、把性能压到位,用户逛一圈下来觉得“像那么回事”,这项目就算成了。