
第一次有人跟我说能不能给羌族刺绣做个虚拟展馆的时候我脑子里冒出来的第一个问题不是技术选型而是一个很实在的疑问这东西到底是给谁看的是给游客在手机上随手点两下还是给研究者戴上头显慢慢看纹样这两个答案会直接把你后面的工作量拉开三倍。后来确认下来目标用户是高校美育课堂的学生、文化馆的普通参观者以及一部分做非遗调研的研究人员运行环境以普通笔记本和机房台式机为主浏览器版本作为补充。这篇内容就是围绕Unity 3D C# 实现的羌族刺绣文化主题虚拟展馆交互漫游系统这个目标把从零到能跑起来的完整链路讲一遍素材怎么准备、建筑怎么建模、漫游手感怎么调、展品交互怎么写、性能怎么压、发布时又该做哪些取舍。如果你手里正好有一个非遗数字化或者校园虚拟展馆的项目这篇基本可以照着抄流程也能少走一些我踩过的弯路。1. 先把虚拟展馆这三个字拆开看很多人一上来就打开Unity新建工程然后发现两周过去了场景里还只有一块地板。问题不在于手速而在于没把需求拆成可执行的模块。虚拟展馆这个词听起来是一个东西实际上它是四件不同的事叠在一起一个空间、一批内容、一套交互、一种发布方式。任何一件没想清楚后面都要返工。1.1 是做一个能转的模型还是做一个能走进去的空间这是最先要定的事。做成能转的模型你只需要一个展台加一个旋转脚本鼠标拖拽看细节工作量大概一周。做成能走进去的空间你就得处理碰撞、地面高度差、门的宽度、相机高度、视锥遮挡、光照烘焙工作量直接翻十倍。羌族刺绣的展品有个特点它是平面的但又有很强的肌理细节。平面的东西如果只是挂在墙上远远看观众三秒钟就失去兴趣但如果能凑近到二十厘米看针脚走向那这个展馆就有存在价值了。所以我的结论是必须做成可漫游的空间而且必须允许贴近观察。这一条决定了整个项目的技术底线——你至少要保证在观众距离绣片 0.3 米时贴图依然清晰不糊。反推回来绣片贴图分辨率就不能低于 2048×2048重点展品建议 4096×4096。这个数字是我实测出来的2048 的贴图在 0.5 米外看不出问题一旦凑到 0.3 米丝线的边缘就开始出现马赛克和拉丝。1.2 Unity、3D、C# 各自负责哪一摊这三者在项目里的分工其实很清晰但新手经常搞混。Unity 是舞台和导演负责渲染、物理、资源管理、打包发布3D 是布景和演员负责展馆的建筑结构、展柜、绣片模型、灯光氛围C# 是剧本和提词器负责观众怎么动、看到什么、点了之后发生什么。把这三者对应到具体文件上你会更清楚。Unity 层你要配置的是渲染管线、质量等级、光照设置、Build Settings3D 层你交付的是 FBX 模型、贴图、材质球C# 层你写的是角色控制器、射线检测、UI 逻辑、数据加载。一个常见的误区是拿 C# 去硬解 3D 应该解决的问题比如用脚本动态缩放模型来省显存——这种做法在展馆项目里只会让美术资产变得无法维护。层级负责内容典型产出常见误用Unity渲染管线、光照、资源、打包场景文件、预制体、Build 配置用脚本硬改渲染参数导致画面失控3D建筑、展柜、绣片、道具FBX、贴图、材质把贴图都塞进一张超大图集导致加载慢C#漫游、交互、数据、UI控制器脚本、数据资产把美术参数写死在代码里1.3 给项目定一个能跑得动的性能预算这条是我吃过亏之后才认真对待的。虚拟展馆最容易掉进的陷阱是美术效果无限堆最后在自己的开发机上跑得挺顺拿到机房的核显机器上直接卡成幻灯片。所以开工前先定预算后面所有决策都围着它转。我的建议目标是在集成显卡比如常见的核显笔记本上1080P 分辨率稳定 30 帧以上在独立显卡上 60 帧。围绕这个目标场景的三角形总数控制在 80 万以内Draw Call 控制在 300 以内同屏动态光源不超过 2 个其余全部走烘焙。这套数字听起来保守但展馆场景是静态的保守一点反而更容易做到看不出差别。提示性能预算一定要在项目第一天定下来并且写进团队文档。等到场景做完再优化你会发现要改的是建模方式、贴图规格和灯光方案等于重做一遍。2. 羌绣的视觉转译从实物照片到能用的3D资产这是整个项目里最耗时间、也最能体现功力的部分。羌族刺绣的纹样密度高、色彩饱和度高、针脚层次多直接拿照片贴上去会显得很平。你需要做的是一套转译流程把实物特征拆成几何、色彩、肌理三层分别处理。2.1 素材采集与图像预处理的门道先说采集。如果条件允许最好能在自然散射光下正对绣片拍摄避免闪光灯直射——闪光灯会在丝线上产生高光斑后期几乎没法完全去掉。拍摄距离保持绣片填满画幅的 80%留一点余量方便校正。每张绣片建议拍三档整体一张、纹样局部一张、针脚微距一张。整体图用来做展品的底图局部图用来做信息面板的插图微距图用来做贴图的细节层。后期处理的关键是建立色彩基准。羌绣的配色很讲究常见的底布是靛蓝或者黑色纹样用大红、桃红、明黄、翠绿、湖蓝对比强烈。如果你在后期里随意拉曲线红色很容易过曝成橙色整个展馆的氛围就偏了。我的做法是先拍一张标准色卡在同一光源下一起拍然后在图像软件里用色卡做白平衡校正再统一套用一个色彩配置文件这样所有展品的色相就是一致的。处理环节参数建议目的白平衡以标准色卡为准不用自动保证多张绣片色相统一输出格式PNG 无损不用 JPG避免二次压缩产生噪点整体图尺寸长边 3000-4000 像素满足 4K 贴图裁切需求微距图尺寸长边 2000 像素以上提取针脚法线细节色彩空间sRGB与 Unity 默认导入设置匹配2.2 展馆建筑的建模模块化才是第一生产力展馆的建筑结构不要指望一次建模做完。我的做法是先用简单的方块搭出灰盒Gray Box确定动线、层高、展墙位置让整个空间的比例在 Unity 里先跑一遍。灰盒阶段的重点不是好看而是走在里面舒不舒服。层高建议 3.6 米左右主通道宽 2.4 到 3 米展墙前的驻足区至少留 1.5 米进深否则观众一贴近展品就会撞到对面的墙。灰盒确认之后再替换成精模。这时候模块化的价值就出来了把展馆拆成标准墙板2 米宽 × 3.6 米高、标准门洞、标准展柜、标准地面砖每块墙板单独建模然后在 Unity 里像搭积木一样拼。这样做的好处有两点。第一美术资源可以复用十面墙其实只有一个模型Draw Call 和内存都省。第二后期改布局只需要拖动预制体不用回建模软件重新导出。展柜的建模有个细节要注意玻璃部分不要用真实透明材质用带一点点透明度的半透明材质加高光效果足够性能开销小很多。真实透明会触发排序问题多层玻璃叠加时容易出现闪烁。2.3 绣片材质把布和线分开做这是让展馆有说服力的核心。如果整张绣片只用一张 Albedo 贴图看起来就是一张印刷海报。要做到这是绣的至少需要三层信息底布的粗纤维质感、丝线的方向性光泽、针脚造成的高度起伏。底布的粗纤维质感靠 Roughness 贴图控制让布的粗糙度在 0.75 到 0.9 之间几乎不反光。丝线的方向性光泽靠 Normal 贴图加各向异性Unity 的 URP 标准材质没有内置各向异性可以自己写一个简单的 Shader或者退而求其次在 Normal 贴图里把丝线的走向做出来配合中等粗糙度0.35 左右也能出效果。针脚的高度起伏直接从微距图转法线这个用常见的图像处理工具就能生成注意强度不要拉满通常 0.6 到 0.8 之间最自然拉满会像塑料浮雕。这里有个我试过之后才明白的点不要给绣片加高光反射的镜面感。丝线是半哑光的加了强高光之后远看像金箔贴画反而失真。2.4 烘焙光照静态场景省下的都是真金白银展馆是静态场景这是它的优势。除了观众手里的相机没有任何东西在动所以完全可以把光照全部烘焙到 Lightmap 里。这样运行时几乎不消耗实时光照计算核显也能跑得动。烘焙参数上我一般这么设Lightmap Resolution 每单位 20 到 40 texel展墙和展柜表面取高值40地板和天花板取低值20。Lightmap Padding 设 2 到 4避免相邻 UV 块之间渗色。Ambient Occlusion 打开Max Distance 设 1 到 2 米能显著增强展柜角落的立体感。烘焙时间上一个 60 米 × 30 米的展馆用中档参数大概要烘 20 到 40 分钟建议放在下班前启动。注意烘焙之前一定要把模型的 UV2 检查一遍Unity 自带的 Generate Lightmap UVs 在简单模型上够用但展柜这种有倒角和凹凸的结构自动生成的 UV 容易出现重叠和拉伸直接表现就是 Lightmap 上出现脏斑。宁可手动展开也别偷这个懒。3. 用C#把漫游手感一点点调出来场景搭好之后大部分时间就是在调感觉。这个环节没有标准答案但有一些参数范围和组合方式是被反复验证过的。我下面给的代码都是实际项目里跑通的版本你可以直接用但参数要根据自己的展馆尺寸微调。3.1 CharacterController 的参数怎么定Unity 里做第一人称漫游首选 CharacterController 而不是 Rigidbody。原因很简单展馆是室内空间观众不应该被物理系统推来推去也不应该有惯性。CharacterController 提供了确定性的移动处理楼梯和门槛也更稳。参数上我的基准配置是Height 1.75、Radius 0.3、Slope Limit 45、Step Offset 0.3、Skin Width 0.02、Center 的 Y 值设 0.875正好是身高的一半。Slope Limit 设 45 度是因为展馆里通常没有需要爬的斜坡超过 45 度的表面应当被当成墙。Step Offset 设 0.3 是为了能自然迈过 30 厘米以内的台阶。移动速度上步行 3.2 米每秒、奔跑 5.5 米每秒比较舒服。低于 2.5 米会让人着急高于 6 米又会晕。鼠标灵敏度是个体差异最大的参数我一般设 2.0 到 2.5并且一定给玩家留一个设置界面去调。using UnityEngine; [RequireComponent(typeof(CharacterController))] public class PlayerWalker : MonoBehaviour { [Header(移动参数)] public float walkSpeed 3.2f; public float runSpeed 5.5f; public float gravity -12f; [Header(视角参数)] public Transform cameraPivot; public float mouseSensitivity 2.2f; public float pitchLimit 80f; private CharacterController cc; private float pitch; private float verticalVelocity; void Awake() { cc GetComponentCharacterController(); } void Update() { Look(); Move(); } void Look() { float mx Input.GetAxis(Mouse X) * mouseSensitivity; float my Input.GetAxis(Mouse Y) * mouseSensitivity; // 水平旋转直接转身体保证移动方向和视线一致 transform.Rotate(Vector3.up * mx); // 俯仰只转相机并且夹紧角度 pitch Mathf.Clamp(pitch - my, -pitchLimit, pitchLimit); cameraPivot.localEulerAngles new Vector3(pitch, 0f, 0f); } void Move() { float h Input.GetAxisRaw(Horizontal); float v Input.GetAxisRaw(Vertical); Vector3 dir (transform.right * h transform.forward * v).normalized; float speed Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed; // 贴地时给一个向下的微小速度避免下台阶时悬空 if (cc.isGrounded verticalVelocity 0f) verticalVelocity -2f; verticalVelocity gravity * Time.deltaTime; Vector3 motion dir * speed Vector3.up * verticalVelocity; cc.Move(motion * Time.deltaTime); } }3.2 视角和移动必须解耦否则一定晕上面那段代码里有一处设计是刻意为之水平旋转转的是身体对象俯仰转的是相机子物体。很多新手会把两者都转相机结果就是看哪儿走哪儿观众一低头就往前走不动了。正确的逻辑应该是视线控制方向、身体控制位移两者分离但方向一致。另外两个细节也很重要。第一鼠标输入要用Input.GetAxis而不是Input.GetAxisRaw因为 GetAxis 自带平滑转视角不会一格一格跳。第二如果你打算同时支持手柄摇杆的视角旋转速度不能和鼠标用同一个灵敏度系数手柄需要额外乘一个约 150 到 200 的倍率并加时间乘数否则转起来像蜗牛。3.3 用射线做展品交互观众走到展品前准星对准绣片面板弹出信息——这是展馆的核心交互。实现方式就是相机中心发一条射线检测是否命中展品层。几个参数值得说。检测距离设 3.5 米这是看得清也够得着的距离太远会导致观众站在大厅中间就能点遍所有展品失去了走近看的仪式感。LayerMask 一定要单独建一个 Exhibit 层不要把地板、墙、道具都算进去否则观众盯着墙也会弹出面板。射线起点用相机位置而不是玩家位置视觉上才准确。using UnityEngine; using TMPro; public class ExhibitInteractor : MonoBehaviour { public Camera viewCamera; public float detectDistance 3.5f; public LayerMask exhibitMask; public GameObject infoPanel; public TextMeshProUGUI titleText; public TextMeshProUGUI descText; public TextMeshProUGUI stitchText; private ExhibitItem current; void Update() { bool hitSomething Physics.Raycast( viewCamera.transform.position, viewCamera.transform.forward, out RaycastHit hit, detectDistance, exhibitMask); ExhibitItem target hitSomething ? hit.collider.GetComponentInParentExhibitItem() : null; if (target ! current) SetCurrent(target); if (current ! null Input.GetMouseButtonDown(0)) OpenInfo(current); } void SetCurrent(ExhibitItem item) { if (current ! null) current.SetHighlight(false); current item; if (current ! null) current.SetHighlight(true); } void OpenInfo(ExhibitItem item) { infoPanel.SetActive(true); titleText.text item.data.displayName; descText.text item.data.description; stitchText.text 针法 item.data.stitchType 纹样 item.data.motifName; } }3.4 展品数据用 ScriptableObject 管起来展品一多最怕的就是数据写在代码里。今天加一件展品要改脚本明天改一句解说词要重新编译这种项目没法维护。正确做法是把每件展品抽成一个 ScriptableObject 资产代码只负责读取。这么做有三个好处策划或者文化顾问可以直接在 Inspector 里改文案不用碰代码展品的解说音频、缩略图、针法分类都能挂在一起查找方便后面要做按针法筛选按纹样分类这类功能直接遍历资产列表就行。using UnityEngine; [CreateAssetMenu(fileName Exhibit, menuName 羌绣展馆/展品数据)] public class ExhibitData : ScriptableObject { public string exhibitId; public string displayName; [TextArea(3, 8)] public string description; public Sprite thumb; public AudioClip narration; public string stitchType; // 挑花、纳花、链子扣、提花 public string motifName; // 羊角花、云云纹、万字纹 public string era; // 年代或传承批次 }using UnityEngine; public class ExhibitItem : MonoBehaviour { public ExhibitData data; public Renderer targetRenderer; private MaterialPropertyBlock block; private static readonly int HighlightId Shader.PropertyToID(_Highlight); void Awake() { block new MaterialPropertyBlock(); } public void SetHighlight(bool on) { if (targetRenderer null) return; targetRenderer.GetPropertyBlock(block); block.SetFloat(HighlightId, on ? 1f : 0f); targetRenderer.SetPropertyBlock(block); } }用 MaterialPropertyBlock 而不是直接改材质是因为后者会生成材质实例展品一多显存就会被吃掉还会破坏合批。4. 让观众愿意停下来动线、高亮和解说的配合漫游系统做得再顺如果观众走完一圈什么也没记住这个项目就是失败的。展馆设计里有个经验值普通参观者在单件展品前停留 15 到 40 秒。你要做的是让这几十秒有信息、有节奏、有视觉引导。4.1 动线和碰撞体别让人卡在墙角动线设计的核心是不走回头路。羌族刺绣展馆可以按源流—针法—纹样—当代应用分四个区观众从入口进入顺时针走一圈刚好回到出口。每个区之间的过渡用一个小空间做节奏缓冲比如一个天井或者一面展示历史照片的过渡墙。碰撞体这块有个很实际的坑展柜的碰撞体不要贴着玻璃要往外扩 0.2 到 0.3 米。原因是观众的相机有一个半径前文设的 0.3如果碰撞体刚好在玻璃上相机贴近时会穿进玻璃内部看到展柜里的黑乎乎一片。往外扩一点观众最近只能走到玻璃前 0.3 米视觉上刚刚好。地面碰撞体建议用一整块带起伏的 Mesh Collider而不是一块块拼接的 Box。拼接的接缝处容易让 CharacterController 卡住或者突然弹一下尤其是接缝有两三厘米高差的时候。4.2 高亮、描边与信息面板的时机高亮不能一进视野就亮那样满屏都在闪。我的做法是分两级准星进入 3.5 米并且对准展品时展品轻微提亮用上面的_Highlight属性控制强度 0.3 左右按下鼠标左键打开面板时展品边缘出现描边或明显提亮。信息面板的呈现也有讲究。面板不要遮住展品本身最好放在屏幕右侧或者左下角让观众能一边看绣片一边看文字。面板里除了名称和描述建议加一行针法和纹样标签这两个信息是羌绣最核心的识别特征观众看完能记住羊角花和挑花这两个词这次参观就算有收获。提示信息面板打开时建议把鼠标视角的输入暂时屏蔽掉或者降低灵敏度。否则观众一边看文字一边不自觉动鼠标画面乱转体验很差。4.3 解说音频与氛围的配合展馆里加环境音是性价比极高的投入。一段低音量的室内混响底噪、几声远处的风声、偶尔的脚步声能让空间的空旷感立刻出来。解说音频建议每件展品控制在 30 到 60 秒用女声或中性声线语速稍慢配一点背景垫乐但垫乐音量要比人声低 12 分贝以上。音频触发上别用进场自动播放。观众可能只是路过突然响起的解说会吓人一跳。正确做法是面板打开时播放关闭时淡出用AudioSource的Play和Stop配合协程做 0.3 秒的淡入淡出。5. 跑得动才算数优化与发布的取舍前面所有的效果最终都要落到能在目标机器上稳定运行这一条上。虚拟展馆的性能瓶颈通常不在三角形数量而在 Draw Call、贴图带宽和透明排序上。5.1 Draw Call 和合批先解决重复劳动展开来看展馆里最多的东西是什么墙、地板、展柜、挂画的框。这些都是重复的。只要它们的材质相同Unity 就可以静态合批。操作上很简单把不动的物体勾选 Static在 Player Settings 里打开 Static Batching。但要注意几个会导致合批失效的情况。第一材质球被代码改过Unity 会为每个物体生成一个材质实例合批直接失效——这个问题我在 3.4 节用 MaterialPropertyBlock 就是为了规避它。第二物体缩放不一致时非均匀缩放的物体会被排除在合批之外所以建模时尽量保持缩放为 1,1,1。第三着色器变体不同也会打断合批同一个场景里别混用好几种自定义 Shader。优化手段适用对象预期收益注意事项Static Batching墙、地、展柜Draw Call 降 40%-60%物体必须不动且材质一致GPU Instancing大量相同道具同屏数量越多收益越大需要材质勾选 Enable Instancing贴图图集UI、小装饰减少材质切换图集尺寸别超过 2048LOD Group复杂展柜、雕塑远处自动降面数需要准备多级模型5.2 贴图分级和 LOD 的实际规则贴图是最容易被忽视的显存杀手。一件绣片 4096 的贴图Albedo、Normal、Roughness 三张加起来大概 100 到 200 MB十件就是 2 GB核显直接爆。解决办法是按观看距离分级主展墙上的重点展品用 4096一般展品用 2048走廊过渡区的装饰用 1024远景的窗格外景直接用纯色或者 512 的小图。LOD 的使用上展柜这种中距离物体做两级就够了近距离用全模型8 米外用简化模型。真正的重头戏是面剔除——把展柜内部看不到的面删掉把墙的背面删掉。这一步用建模软件里的删除内部面就能做能省掉不少三角形。5.3 WebGL 和 PC 端发布路子不一样如果只在教室的电脑上运行直接出 Windows 版最省心IL2CPP 的 C# 性能最好贴图和音频也没有额外限制。但如果要挂到网页上给校外的人看就是另一套逻辑了。WebGL 版本的限制不少内存上限通常在 2 GB 左右超出浏览器就崩贴图压缩格式要用平台支持的否则加载极慢音频要用压缩格式且不能播放太长的无损音频网络请求只能用浏览器允许的方式。我的做法是给 WebGL 版本单独做一套资源贴图整体降一半分辨率展品数量减到核心的十件音频全部转成压缩格式场景烘焙分辨率也降低。这样出来的包体会从几百兆降到七八十兆加载体验好很多。发布方式优势主要限制适用场景Windows PC性能好、无内存限制需要安装包和运行环境教室、文化馆现场WebGL打开即用、无需安装内存和压缩格式限制多线上浏览、手机端移动端 APK便于随身查看触控操作需要重新设计展会扫码体验6. 踩过的坑文档里不会写的那几个问题下面这几个问题我在项目里都真实遇到过而且每一个都花了不止一天才定位。写出来给你省点时间。6.1 阴影闪烁和 Bias 的取值展馆里如果出现明暗交界的条纹或者阴影边缘像被啃过一样多半是 Shadow Bias 的问题。阴影贴图的精度是有限的斜面上容易出现自阴影瑕疵。调法是先把 Bias 调到 0.05 左右试还闪就加到 0.1同时把 Normal Bias 设成 0.3 到 0.5。但要注意 Bias 调太大阴影会从物体底部漏出去看起来像浮在空中。真正稳妥的方案其实是展馆是静态的主光源用烘焙只有角色身上需要一点实时阴影。把实时阴影的距离调短20 米以内问题会少一大半。6.2 穿模的三种成因第一类是碰撞体和视觉模型不匹配比如展柜加了个装饰底座但忘了加碰撞体观众直接走进去。第二类是相机半径大于碰撞间隙比如两排展柜之间只有 0.8 米间隙相机半径 0.3理论上能过但实际走过去会被卡住。第三类是快速移动时的隧穿一帧移动距离超过了物体厚度直接穿过去。解决办法分别是建一个碰撞体检查的调试视图定期核对、保持通道净宽大于 1.5 米、在移动逻辑里限制单帧最大位移。6.3 中文显示和字体图集Unity 默认的 Text 组件在中文场景下问题很多字多了会花字少了会缺字。换成 TextMeshPro 之后好很多但仍要注意字体图集。中文常用字三千多个如果按常用字集生成图集会很大我的做法是先生成项目里实际用到的字符集Unity 的 Font Asset Creator 支持粘贴字符列表只生成用到的字图集能小一个数量级。另外记得在 Font Asset 设置里把 Atlas Resolution 设成 1024 或 2048太小会糊太大会浪费显存。6.4 打包之后才发现的意外有几个问题只在打包后出现开发模式下一切正常特别容易让人崩溃。一个是资源路径用了Resources.Load在 WebGL 里路径大小写敏感Windows 上测试正常但网页上加载失败。另一个是 PlayerPrefs 在 WebGL 里存在浏览器本地存储隐私模式下可能失效不能用来存关键进度。还有一个是时间相关的逻辑WebGL 里Time.timeScale的表现和 PC 略有差异涉及动画播完的回调最好用协程或者状态判断不要硬编码等待秒数。最后一个建议每做完一个模块就打包一次别等到项目末期才第一次出包。我见过太多项目在最后一周才发现打包失败而问题往往只是某个插件不支持目标平台。如果你也在做类似的非遗数字化展馆我个人最想提醒的是不要一开始就追求画面精度。先把能走、能看、能懂这三件事做通剩下的都是锦上添花。